017f10e60b
- Ambiguous lookups (2+ contacts on one email) now dedupe by Contact Id and fetch each candidate's full record so the picker shows name + account + email instead of just the bare search payload. - Per-row "תייק" button on each linked case lets the user pick the correct case to file to when a contact has multiple cases; bottom "תייק לתיק הזה" button is hidden in that scenario. - "→ חזור לרשימת אנשי קשר" link restores the candidate list after drilling into one of them. - Contact name + case rows in the sidebar are now hyperlinks that open the entity in Klear. - AddInHost.LaunchFileToCaseAsync accepts an optional MatchResult and auto-files when the sidebar resolved a single linked case, skipping the manual search dialog. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
14 KiB
C#
356 lines
14 KiB
C#
using System;
|
|
using System.Collections.ObjectModel;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using MarcusLaw.OutlookAddin.Core.Models;
|
|
using MarcusLaw.OutlookAddin.Core.Services;
|
|
using Serilog;
|
|
|
|
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
|
{
|
|
/// <summary>
|
|
/// Possible states of the sidebar. Each maps to a Visibility binding
|
|
/// in <c>ReadingPaneView.xaml</c>.
|
|
/// </summary>
|
|
public enum ReadingPaneState
|
|
{
|
|
Empty,
|
|
Loading,
|
|
Match,
|
|
NoMatch,
|
|
Ambiguous,
|
|
Error
|
|
}
|
|
|
|
public sealed partial class ReadingPaneViewModel : ObservableObject
|
|
{
|
|
private readonly IMatchingService _matchingService;
|
|
private readonly ILogger _logger;
|
|
private CancellationTokenSource? _activeLookup;
|
|
// Stashes the ambiguous MatchResult while the user is drilling into
|
|
// one of its candidates, so the "→ חזור" button can restore it.
|
|
private MatchResult? _ambiguousReturnTarget;
|
|
|
|
[ObservableProperty]
|
|
private ReadingPaneState state = ReadingPaneState.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string senderEmail = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private MatchResult? match;
|
|
|
|
[ObservableProperty]
|
|
private string? errorMessage;
|
|
|
|
[ObservableProperty]
|
|
private string? ambiguousMessage;
|
|
|
|
[ObservableProperty]
|
|
private string? espoCrmBaseUrl;
|
|
|
|
public ObservableCollection<CaseEntity> RecentCases { get; } = new ObservableCollection<CaseEntity>();
|
|
|
|
public ObservableCollection<ContactEntity> Candidates { get; } = new ObservableCollection<ContactEntity>();
|
|
|
|
public Visibility EmptyVisibility => Vis(State == ReadingPaneState.Empty);
|
|
public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading);
|
|
public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match);
|
|
public Visibility NoMatchVisibility => Vis(State == ReadingPaneState.NoMatch);
|
|
public Visibility AmbiguousVisibility => Vis(State == ReadingPaneState.Ambiguous);
|
|
public Visibility ErrorVisibility => Vis(State == ReadingPaneState.Error);
|
|
public Visibility AccountVisibility => Vis(Match?.Account != null);
|
|
public Visibility NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0);
|
|
public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match);
|
|
|
|
/// <summary>
|
|
/// True when the matched contact has 2+ linked cases. The sidebar
|
|
/// uses this to hide the bottom "תייק לתיק הזה" button (which is
|
|
/// ambiguous in that scenario) and to surface the per-row "תייק"
|
|
/// buttons that let the user pick which case to file to.
|
|
/// </summary>
|
|
public Visibility MultiCaseHintVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count >= 2);
|
|
public Visibility SingleCaseFileVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 1);
|
|
|
|
/// <summary>
|
|
/// "→ חזור לרשימת אנשי קשר" is only shown when the user drilled
|
|
/// into a candidate from the ambiguous picker; not in a normal
|
|
/// single-match lookup.
|
|
/// </summary>
|
|
public Visibility BackToCandidatesVisibility => Vis(State == ReadingPaneState.Match && _ambiguousReturnTarget != null);
|
|
|
|
/// <summary>
|
|
/// Raised when the user clicks "File to this case" (carries the chosen
|
|
/// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null).
|
|
/// Host wires this to <c>AddInHost.LaunchFileToCaseAsync</c>.
|
|
/// </summary>
|
|
public event EventHandler<FileFromSidebarEventArgs>? FileRequested;
|
|
|
|
/// <summary>
|
|
/// Raised when the user clicks "Open in EspoCRM". The host opens the URL.
|
|
/// </summary>
|
|
public event EventHandler<string>? OpenInBrowserRequested;
|
|
|
|
public ReadingPaneViewModel(IMatchingService matchingService, ILogger logger)
|
|
{
|
|
_matchingService = matchingService ?? throw new ArgumentNullException(nameof(matchingService));
|
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
}
|
|
|
|
partial void OnStateChanged(ReadingPaneState value)
|
|
{
|
|
OnPropertyChanged(nameof(EmptyVisibility));
|
|
OnPropertyChanged(nameof(LoadingVisibility));
|
|
OnPropertyChanged(nameof(MatchVisibility));
|
|
OnPropertyChanged(nameof(NoMatchVisibility));
|
|
OnPropertyChanged(nameof(AmbiguousVisibility));
|
|
OnPropertyChanged(nameof(ErrorVisibility));
|
|
OnPropertyChanged(nameof(ActionsVisibility));
|
|
OnPropertyChanged(nameof(NoCasesVisibility));
|
|
OnPropertyChanged(nameof(MultiCaseHintVisibility));
|
|
OnPropertyChanged(nameof(SingleCaseFileVisibility));
|
|
OnPropertyChanged(nameof(BackToCandidatesVisibility));
|
|
FileToThisCommand.NotifyCanExecuteChanged();
|
|
FileElsewhereCommand.NotifyCanExecuteChanged();
|
|
OpenInEspoCrmCommand.NotifyCanExecuteChanged();
|
|
}
|
|
|
|
partial void OnMatchChanged(MatchResult? value)
|
|
{
|
|
RecentCases.Clear();
|
|
if (value?.RecentCases != null)
|
|
{
|
|
foreach (var c in value.RecentCases) RecentCases.Add(c);
|
|
}
|
|
Candidates.Clear();
|
|
if (value?.Candidates != null)
|
|
{
|
|
foreach (var c in value.Candidates) Candidates.Add(c);
|
|
}
|
|
OnPropertyChanged(nameof(AccountVisibility));
|
|
OnPropertyChanged(nameof(NoCasesVisibility));
|
|
OnPropertyChanged(nameof(MultiCaseHintVisibility));
|
|
OnPropertyChanged(nameof(SingleCaseFileVisibility));
|
|
OnPropertyChanged(nameof(BackToCandidatesVisibility));
|
|
}
|
|
|
|
public void Reset()
|
|
{
|
|
CancelInFlight();
|
|
SenderEmail = string.Empty;
|
|
Match = null;
|
|
ErrorMessage = null;
|
|
AmbiguousMessage = null;
|
|
_ambiguousReturnTarget = null;
|
|
State = ReadingPaneState.Empty;
|
|
}
|
|
|
|
public async Task LoadAsync(string senderEmail)
|
|
{
|
|
CancelInFlight();
|
|
if (string.IsNullOrWhiteSpace(senderEmail))
|
|
{
|
|
Reset();
|
|
return;
|
|
}
|
|
|
|
SenderEmail = senderEmail;
|
|
// New email selection — drop any stashed candidate-list state.
|
|
_ambiguousReturnTarget = null;
|
|
State = ReadingPaneState.Loading;
|
|
|
|
var cts = new CancellationTokenSource();
|
|
_activeLookup = cts;
|
|
|
|
try
|
|
{
|
|
var result = await _matchingService.LookupAsync(senderEmail, cts.Token).ConfigureAwait(true);
|
|
if (cts.IsCancellationRequested) return;
|
|
|
|
if (result == null)
|
|
{
|
|
Match = null;
|
|
State = ReadingPaneState.NoMatch;
|
|
return;
|
|
}
|
|
|
|
if (result.IsAmbiguous)
|
|
{
|
|
Match = result;
|
|
AmbiguousMessage = $"נמצאו {result.CandidateCount} אנשי קשר עם הכתובת הזו — נדרשת בחירה ידנית.";
|
|
State = ReadingPaneState.Ambiguous;
|
|
return;
|
|
}
|
|
|
|
Match = result;
|
|
State = ReadingPaneState.Match;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Selection changed before lookup finished — ignore.
|
|
}
|
|
catch (EspoCrmAuthorizationException)
|
|
{
|
|
ErrorMessage = "מפתח ה-API נדחה. עדכן בהגדרות.";
|
|
State = ReadingPaneState.Error;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warning(ex, "ReadingPaneViewModel.LoadAsync failed for {Email}", senderEmail);
|
|
ErrorMessage = "שגיאת התחברות לשרת.";
|
|
State = ReadingPaneState.Error;
|
|
}
|
|
}
|
|
|
|
private void CancelInFlight()
|
|
{
|
|
try { _activeLookup?.Cancel(); } catch { /* ignore */ }
|
|
_activeLookup = null;
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanFileToThis))]
|
|
private void FileToThis()
|
|
{
|
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!));
|
|
}
|
|
|
|
// Bottom "תייק לתיק הזה" only makes sense with exactly one linked
|
|
// case. 0 cases → nothing to file to; 2+ cases → ambiguous, the
|
|
// per-row "תייק" buttons handle the pick.
|
|
private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null && RecentCases.Count == 1;
|
|
|
|
[RelayCommand]
|
|
private void FileToCase(CaseEntity? caseEntity)
|
|
{
|
|
if (caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id) || Match == null) return;
|
|
// Build a synthetic MatchResult so the host's
|
|
// TryResolveSidebarAutoTarget path sees exactly one case and
|
|
// auto-files without re-opening the search dialog.
|
|
var synthetic = new MatchResult
|
|
{
|
|
SenderEmail = Match.SenderEmail,
|
|
Contact = Match.Contact,
|
|
Account = Match.Account,
|
|
RecentCases = new System.Collections.Generic.List<CaseEntity> { caseEntity },
|
|
CandidateCount = 1
|
|
};
|
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(synthetic));
|
|
}
|
|
|
|
[RelayCommand(CanExecute = nameof(CanFileElsewhere))]
|
|
private void FileElsewhere()
|
|
{
|
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
|
|
}
|
|
|
|
private bool CanFileElsewhere() => true;
|
|
|
|
[RelayCommand(CanExecute = nameof(CanOpenInCrm))]
|
|
private void OpenInEspoCrm()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || Match?.Contact == null) return;
|
|
var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Contact/view/" + Match.Contact.Id;
|
|
OpenInBrowserRequested?.Invoke(this, url);
|
|
}
|
|
|
|
private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null;
|
|
|
|
[RelayCommand]
|
|
private void OpenCaseInCrm(CaseEntity? caseEntity)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(EspoCrmBaseUrl) || caseEntity == null || string.IsNullOrWhiteSpace(caseEntity.Id)) return;
|
|
var url = EspoCrmBaseUrl!.TrimEnd('/') + "/#Case/view/" + caseEntity.Id;
|
|
OpenInBrowserRequested?.Invoke(this, url);
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenManualSearch()
|
|
{
|
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void BackToCandidates()
|
|
{
|
|
if (_ambiguousReturnTarget == null) return;
|
|
var stash = _ambiguousReturnTarget;
|
|
// Clear stash before we re-enter Ambiguous so visibility refresh
|
|
// (triggered by OnMatchChanged) sees the cleared state and hides
|
|
// the back button until the user drills in again.
|
|
_ambiguousReturnTarget = null;
|
|
Match = stash;
|
|
AmbiguousMessage = $"נמצאו {stash.CandidateCount} אנשי קשר עם הכתובת הזו.";
|
|
State = ReadingPaneState.Ambiguous;
|
|
}
|
|
|
|
/// <summary>
|
|
/// User picked one of the ambiguous candidates from the sidebar.
|
|
/// Load that contact's full bundle (account + cases) and transition
|
|
/// to the Match state so the user can pick a case or file.
|
|
/// </summary>
|
|
[RelayCommand]
|
|
private async Task SelectCandidateAsync(ContactEntity? candidate)
|
|
{
|
|
if (candidate == null || string.IsNullOrWhiteSpace(candidate.Id)) return;
|
|
|
|
CancelInFlight();
|
|
var cts = new CancellationTokenSource();
|
|
_activeLookup = cts;
|
|
|
|
var rememberedEmail = SenderEmail;
|
|
// Stash the ambiguous result so "→ חזור" can restore it.
|
|
if (Match?.IsAmbiguous == true)
|
|
{
|
|
_ambiguousReturnTarget = Match;
|
|
}
|
|
State = ReadingPaneState.Loading;
|
|
|
|
try
|
|
{
|
|
var result = await _matchingService.LookupContactAsync(candidate.Id, rememberedEmail, cts.Token).ConfigureAwait(true);
|
|
if (cts.IsCancellationRequested) return;
|
|
|
|
if (result == null || result.Contact == null)
|
|
{
|
|
ErrorMessage = "טעינת איש הקשר נכשלה.";
|
|
State = ReadingPaneState.Error;
|
|
return;
|
|
}
|
|
|
|
Match = result;
|
|
State = ReadingPaneState.Match;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Selection changed before lookup finished — ignore.
|
|
}
|
|
catch (EspoCrmAuthorizationException)
|
|
{
|
|
ErrorMessage = "מפתח ה-API נדחה. עדכן בהגדרות.";
|
|
State = ReadingPaneState.Error;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warning(ex, "ReadingPaneViewModel.SelectCandidateAsync failed for {ContactId}", candidate.Id);
|
|
ErrorMessage = "שגיאת התחברות לשרת.";
|
|
State = ReadingPaneState.Error;
|
|
}
|
|
}
|
|
|
|
private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed;
|
|
}
|
|
|
|
public sealed class FileFromSidebarEventArgs : EventArgs
|
|
{
|
|
public MatchResult? PreselectedMatch { get; }
|
|
|
|
public FileFromSidebarEventArgs(MatchResult? preselected)
|
|
{
|
|
PreselectedMatch = preselected;
|
|
}
|
|
}
|
|
}
|