feat(sidebar): inline candidate picker, per-case file action, back navigation

- 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>
This commit is contained in:
PointStar
2026-05-20 12:57:51 +03:00
parent be5edc203c
commit 017f10e60b
7 changed files with 398 additions and 86 deletions
@@ -17,6 +17,13 @@ namespace MarcusLaw.OutlookAddin.Core.Models
public int CandidateCount { get; set; }
/// <summary>
/// When <see cref="IsAmbiguous"/> is true, holds the candidate
/// contacts that share the sender's email so the sidebar can show
/// a picker instead of bouncing the user into manual search.
/// </summary>
public List<ContactEntity> Candidates { get; set; } = new List<ContactEntity>();
public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
}
}
@@ -13,6 +13,15 @@ namespace MarcusLaw.OutlookAddin.Core.Services
/// </summary>
Task<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default);
/// <summary>
/// Loads the full contact + account + recent-cases bundle for a
/// specific contact id. Used when the sender's email matches
/// multiple contacts and the user picks one from the candidate
/// list — we then drill into that contact's cases without
/// dropping back to manual search.
/// </summary>
Task<MatchResult?> LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default);
void Invalidate(string senderEmail);
}
}
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
@@ -44,20 +45,93 @@ namespace MarcusLaw.OutlookAddin.Core.Services
return null;
}
if (matches.Count > 1)
// EspoCRM's /EmailAddress/search can return the same Contact
// more than once when the contact has the searched-for
// address registered under multiple emailAddressData rows.
// Dedupe by Id so the sidebar shows one row per unique person.
var distinctIds = matches
.Where(m => !string.IsNullOrWhiteSpace(m.Id))
.Select(m => m.Id)
.Distinct(StringComparer.Ordinal)
.Take(20)
.ToList();
if (distinctIds.Count == 0)
{
_cache.Set(key, (MatchResult?)null, CacheExpiry);
return null;
}
if (distinctIds.Count == 1)
{
var single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false);
_cache.Set<MatchResult?>(key, single, CacheExpiry);
return single;
}
// Two or more genuinely distinct contacts. Fetch each one's
// full record (in parallel) so the picker can show real
// name + account, not just the bare email payload.
var fetched = await Task.WhenAll(
distinctIds.Select(id => SafeGetContactAsync(id, cancellationToken))
).ConfigureAwait(false);
var candidates = fetched
.Where(c => c != null && !string.IsNullOrWhiteSpace(c!.Id))
.Select(c => c!)
.ToList();
var ambiguous = new MatchResult
{
SenderEmail = senderEmail,
IsAmbiguous = true,
CandidateCount = matches.Count
CandidateCount = candidates.Count,
Candidates = candidates
};
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous;
}
catch (EspoCrmAuthorizationException)
{
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService lookup failed for {Email}", senderEmail);
return null;
}
}
var contactRef = matches[0];
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
public async Task<MatchResult?> LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(contactId)) return null;
try
{
return await LoadContactBundleAsync(contactId, senderEmail ?? string.Empty, cancellationToken).ConfigureAwait(false);
}
catch (EspoCrmAuthorizationException)
{
throw;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService: LookupContactAsync failed for {ContactId}", contactId);
return null;
}
}
private async Task<MatchResult> LoadContactBundleAsync(string contactId, string senderEmail, CancellationToken cancellationToken)
{
var contact = await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false);
AccountEntity? account = null;
if (!string.IsNullOrWhiteSpace(contact.AccountId))
@@ -68,7 +142,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
}
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for {Email}", contact.AccountId, senderEmail);
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for contact {ContactId}", contact.AccountId, contactId);
}
}
@@ -87,7 +161,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
_logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
}
var result = new MatchResult
return new MatchResult
{
SenderEmail = senderEmail,
Contact = contact,
@@ -95,21 +169,19 @@ namespace MarcusLaw.OutlookAddin.Core.Services
RecentCases = cases,
CandidateCount = 1
};
}
_cache.Set<MatchResult?>(key, result, CacheExpiry);
return result;
}
catch (EspoCrmAuthorizationException)
private async Task<ContactEntity?> SafeGetContactAsync(string contactId, CancellationToken cancellationToken)
{
throw;
}
catch (OperationCanceledException)
try
{
throw;
return await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
catch (EspoCrmAuthorizationException) { throw; }
catch (Exception ex)
{
_logger.Warning(ex, "MatchingService lookup failed for {Email}", senderEmail);
_logger.Warning(ex, "MatchingService: candidate contact fetch failed for {Id}", contactId);
return null;
}
}
@@ -30,6 +30,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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;
@@ -51,6 +54,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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);
@@ -61,6 +66,22 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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).
@@ -89,6 +110,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
OnPropertyChanged(nameof(ErrorVisibility));
OnPropertyChanged(nameof(ActionsVisibility));
OnPropertyChanged(nameof(NoCasesVisibility));
OnPropertyChanged(nameof(MultiCaseHintVisibility));
OnPropertyChanged(nameof(SingleCaseFileVisibility));
OnPropertyChanged(nameof(BackToCandidatesVisibility));
FileToThisCommand.NotifyCanExecuteChanged();
FileElsewhereCommand.NotifyCanExecuteChanged();
OpenInEspoCrmCommand.NotifyCanExecuteChanged();
@@ -101,8 +125,16 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
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()
@@ -112,6 +144,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
Match = null;
ErrorMessage = null;
AmbiguousMessage = null;
_ambiguousReturnTarget = null;
State = ReadingPaneState.Empty;
}
@@ -125,6 +158,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
}
SenderEmail = senderEmail;
// New email selection — drop any stashed candidate-list state.
_ambiguousReturnTarget = null;
State = ReadingPaneState.Loading;
var cts = new CancellationTokenSource();
@@ -182,7 +217,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!));
}
private bool CanFileToThis() => State == ReadingPaneState.Match && Match?.Contact != null;
// 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()
@@ -202,12 +258,88 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
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;
}
+62 -3
View File
@@ -62,6 +62,29 @@
<!-- Ambiguous -->
<StackPanel Visibility="{Binding AmbiguousVisibility}">
<TextBlock Text="{Binding AmbiguousMessage}" Foreground="DarkOrange" />
<TextBlock Style="{StaticResource LabelStyle}" Text="בחר איש קשר:" Margin="0,8,0,2" />
<ItemsControl ItemsSource="{Binding Candidates}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
<Button Command="{Binding DataContext.SelectCandidateCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
Background="Transparent"
BorderThickness="0"
Padding="0"
Cursor="Hand"
HorizontalContentAlignment="Right"
HorizontalAlignment="Stretch">
<StackPanel HorizontalAlignment="Stretch">
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" Foreground="#0066CC" />
<TextBlock Text="{Binding AccountName}" Foreground="DimGray" FontSize="11" />
<TextBlock Text="{Binding EmailAddress}" Foreground="Gray" FontSize="10" />
</StackPanel>
</Button>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="פתח חיפוש ידני"
Command="{Binding OpenManualSearchCommand}"
Style="{StaticResource LinkButtonStyle}"
@@ -70,8 +93,20 @@
<!-- Match -->
<StackPanel Visibility="{Binding MatchVisibility}">
<Button Content="→ חזור לרשימת אנשי קשר"
Command="{Binding BackToCandidatesCommand}"
Style="{StaticResource LinkButtonStyle}"
HorizontalAlignment="Right"
Margin="0,0,0,4"
Visibility="{Binding BackToCandidatesVisibility}" />
<TextBlock Style="{StaticResource LabelStyle}" Text="איש קשר" />
<TextBlock Text="{Binding Match.Contact.Name}" FontSize="14" FontWeight="Bold" />
<TextBlock FontSize="14" FontWeight="Bold">
<Hyperlink Command="{Binding OpenInEspoCrmCommand}"
TextDecorations="{x:Null}"
Foreground="#0066CC">
<Run Text="{Binding Match.Contact.Name, Mode=OneWay}" />
</Hyperlink>
</TextBlock>
<TextBlock Text="{Binding Match.Contact.EmailAddress}" Foreground="Gray" />
<TextBlock Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" />
@@ -82,14 +117,33 @@
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4">
<StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock>
<Hyperlink Command="{Binding DataContext.OpenCaseInCrmCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
TextDecorations="{x:Null}"
Foreground="#0066CC">
<Run Text="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</Hyperlink>
</TextBlock>
<TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}"
Foreground="Gray" FontSize="10" />
</StackPanel>
<Button Grid.Column="1"
Content="תייק"
Command="{Binding DataContext.FileToCaseCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
Padding="8,2"
Margin="6,0,0,0"
VerticalAlignment="Top"
ToolTip="תייק את המייל הנוכחי לתיק הזה" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -110,7 +164,12 @@
<Button Content="תייק לתיק הזה"
Command="{Binding FileToThisCommand}"
Padding="8,4" Margin="0,0,0,4"
HorizontalAlignment="Stretch" />
HorizontalAlignment="Stretch"
Visibility="{Binding SingleCaseFileVisibility}" />
<TextBlock Text="יש כמה תיקים מקושרים — בחר תיק מהרשימה למעלה."
Foreground="Gray" FontStyle="Italic" FontSize="10"
Margin="0,0,0,4"
Visibility="{Binding MultiCaseHintVisibility}" />
<Button Content="תייק למקום אחר…"
Command="{Binding FileElsewhereCommand}"
Padding="8,4" Margin="0,0,0,4"
+35 -2
View File
@@ -643,7 +643,14 @@ namespace OutlookAddin.Services
/// current Explorer selection, opens the search dialog, files on success,
/// and stamps categories on the chosen MailItems.
/// </summary>
public async Task LaunchFileToCaseAsync()
public Task LaunchFileToCaseAsync() => LaunchFileToCaseAsync(null);
/// <summary>
/// Same as <see cref="LaunchFileToCaseAsync()"/>, but when the sidebar
/// already resolved a single linked case for the sender we skip the
/// search dialog and file directly to that case.
/// </summary>
public async Task LaunchFileToCaseAsync(MatchResult? preselectedMatch)
{
if (!IsConfigured)
{
@@ -673,6 +680,16 @@ namespace OutlookAddin.Services
try
{
var envelopes = mailItems.Select(MailItemExtractor.Extract).ToList();
FilingTarget? target = TryResolveSidebarAutoTarget(preselectedMatch);
if (target != null)
{
Logger.Information(
"Auto-filing {Count} mail item(s) from sidebar to {Type}/{Id} ({Name})",
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
}
else
{
var firstEnvelope = envelopes[0];
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
@@ -689,7 +706,7 @@ namespace OutlookAddin.Services
ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType);
if (ok != true) return;
var target = vm.GetSelectedFilingTarget();
target = vm.GetSelectedFilingTarget();
if (target == null)
{
Logger.Warning(
@@ -706,6 +723,7 @@ namespace OutlookAddin.Services
Logger.Information(
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
}
var summary = await FilingService!.FileAsync(envelopes, target).ConfigureAwait(true);
@@ -722,6 +740,21 @@ namespace OutlookAddin.Services
}
}
// Sidebar's "תייק לתיק הזה" makes a hard promise about which case
// gets the mail — only honour it when the match resolved a single
// linked case. With 0 or 2+ cases there is no unambiguous "this",
// and the caller falls back to the manual search dialog.
private static FilingTarget? TryResolveSidebarAutoTarget(MatchResult? match)
{
if (match?.RecentCases == null || match.RecentCases.Count != 1) return null;
var c = match.RecentCases[0];
if (string.IsNullOrWhiteSpace(c.Id)) return null;
var label = !string.IsNullOrWhiteSpace(c.Number)
? $"[{c.Number}] {c.Name}"
: (c.Name ?? c.Id);
return new FilingTarget("Case", c.Id, label);
}
private List<Outlook.MailItem> ReadCurrentSelection()
{
var result = new List<Outlook.MailItem>();
@@ -109,7 +109,7 @@ namespace OutlookAddin.Services
vm.FileRequested += async (s, e) =>
{
try { await _host.LaunchFileToCaseAsync().ConfigureAwait(true); }
try { await _host.LaunchFileToCaseAsync(e.PreselectedMatch).ConfigureAwait(true); }
catch (Exception ex) { _logger.Warning(ex, "Sidebar file-request handler failed"); }
};
vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url);