2368764251
Core: - IEspoCrmClient.ListCasesForContactAsync (paged where[]=linkedWith query) - EspoListResponse<T> generic envelope for /list endpoints - IMatchingService + MatchingService: EmailAddress/search → Contact → Account → recent 5 Cases. MemoryCache 10-min TTL; caches null and ambiguous results too. Returns null for 0 matches, IsAmbiguous=true for 2+, populated MatchResult for exactly 1. - MatchResult DTO with Contact / Account / RecentCases / IsAmbiguous UI: - ReadingPaneView (WPF UserControl, RTL): 6 states — Empty / Loading / Match / NoMatch / Ambiguous / Error — switched via Visibility bindings - ReadingPaneViewModel: state machine, FileRequested + OpenInBrowserRequested events, Reset()/LoadAsync() with cancellation chain (every selection cancels the previous lookup) Host: - ExplorerSelectionHandler: subscribes to Outlook.Explorer.SelectionChange, 200ms debounce via Timer, dedupes by last sender, marshals to WPF dispatcher before reading COM state - SidebarController: attaches a CustomTaskPane per Explorer (ElementHost wrapping ReadingPaneView), tracks bindings by COM identity, unhooks on Explorer.Close. Sidebar defers attach until CRM client is configured (subscribes to CrmClientChanged) - AddInHost: MatchingService field, MemoryCache shared across lookups, InitializeSidebar(taskPanes) invoked from ThisAddIn_Startup Core/UI/Tests green (38 tests passing); VSTO host still needs VS Installer Repair locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
7.8 KiB
C#
224 lines
7.8 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;
|
|
|
|
[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 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>
|
|
/// 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));
|
|
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);
|
|
}
|
|
OnPropertyChanged(nameof(AccountVisibility));
|
|
OnPropertyChanged(nameof(NoCasesVisibility));
|
|
}
|
|
|
|
public void Reset()
|
|
{
|
|
CancelInFlight();
|
|
SenderEmail = string.Empty;
|
|
Match = null;
|
|
ErrorMessage = null;
|
|
AmbiguousMessage = null;
|
|
State = ReadingPaneState.Empty;
|
|
}
|
|
|
|
public async Task LoadAsync(string senderEmail)
|
|
{
|
|
CancelInFlight();
|
|
if (string.IsNullOrWhiteSpace(senderEmail))
|
|
{
|
|
Reset();
|
|
return;
|
|
}
|
|
|
|
SenderEmail = senderEmail;
|
|
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!));
|
|
}
|
|
|
|
private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null;
|
|
|
|
[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 OpenManualSearch()
|
|
{
|
|
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|