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; } 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; public DateTimeOffset RetrievedAt { get; set; } = DateTimeOffset.UtcNow;
} }
} }
@@ -13,6 +13,15 @@ namespace MarcusLaw.OutlookAddin.Core.Services
/// </summary> /// </summary>
Task<MatchResult?> LookupAsync(string senderEmail, CancellationToken cancellationToken = default); 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); void Invalidate(string senderEmail);
} }
} }
@@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models; using MarcusLaw.OutlookAddin.Core.Models;
@@ -44,20 +45,93 @@ namespace MarcusLaw.OutlookAddin.Core.Services
return null; 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 var ambiguous = new MatchResult
{ {
SenderEmail = senderEmail, SenderEmail = senderEmail,
IsAmbiguous = true, IsAmbiguous = true,
CandidateCount = matches.Count CandidateCount = candidates.Count,
Candidates = candidates
}; };
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry); _cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
return ambiguous; 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]; public async Task<MatchResult?> LookupContactAsync(string contactId, string? senderEmail = null, CancellationToken cancellationToken = default)
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false); {
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; AccountEntity? account = null;
if (!string.IsNullOrWhiteSpace(contact.AccountId)) if (!string.IsNullOrWhiteSpace(contact.AccountId))
@@ -68,7 +142,7 @@ namespace MarcusLaw.OutlookAddin.Core.Services
} }
catch (Exception ex) 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); _logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
} }
var result = new MatchResult return new MatchResult
{ {
SenderEmail = senderEmail, SenderEmail = senderEmail,
Contact = contact, Contact = contact,
@@ -95,21 +169,19 @@ namespace MarcusLaw.OutlookAddin.Core.Services
RecentCases = cases, RecentCases = cases,
CandidateCount = 1 CandidateCount = 1
}; };
}
_cache.Set<MatchResult?>(key, result, CacheExpiry); private async Task<ContactEntity?> SafeGetContactAsync(string contactId, CancellationToken cancellationToken)
return result;
}
catch (EspoCrmAuthorizationException)
{ {
throw; try
}
catch (OperationCanceledException)
{ {
throw; return await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false);
} }
catch (OperationCanceledException) { throw; }
catch (EspoCrmAuthorizationException) { throw; }
catch (Exception ex) 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; return null;
} }
} }
@@ -30,6 +30,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private readonly IMatchingService _matchingService; private readonly IMatchingService _matchingService;
private readonly ILogger _logger; private readonly ILogger _logger;
private CancellationTokenSource? _activeLookup; 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] [ObservableProperty]
private ReadingPaneState state = ReadingPaneState.Empty; private ReadingPaneState state = ReadingPaneState.Empty;
@@ -51,6 +54,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public ObservableCollection<CaseEntity> RecentCases { get; } = new ObservableCollection<CaseEntity>(); 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 EmptyVisibility => Vis(State == ReadingPaneState.Empty);
public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading); public Visibility LoadingVisibility => Vis(State == ReadingPaneState.Loading);
public Visibility MatchVisibility => Vis(State == ReadingPaneState.Match); 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 NoCasesVisibility => Vis(State == ReadingPaneState.Match && RecentCases.Count == 0);
public Visibility ActionsVisibility => Vis(State == ReadingPaneState.Match); 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> /// <summary>
/// Raised when the user clicks "File to this case" (carries the chosen /// Raised when the user clicks "File to this case" (carries the chosen
/// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null). /// MatchResult.Contact / RecentCases pick) or "File elsewhere" (null).
@@ -89,6 +110,9 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
OnPropertyChanged(nameof(ErrorVisibility)); OnPropertyChanged(nameof(ErrorVisibility));
OnPropertyChanged(nameof(ActionsVisibility)); OnPropertyChanged(nameof(ActionsVisibility));
OnPropertyChanged(nameof(NoCasesVisibility)); OnPropertyChanged(nameof(NoCasesVisibility));
OnPropertyChanged(nameof(MultiCaseHintVisibility));
OnPropertyChanged(nameof(SingleCaseFileVisibility));
OnPropertyChanged(nameof(BackToCandidatesVisibility));
FileToThisCommand.NotifyCanExecuteChanged(); FileToThisCommand.NotifyCanExecuteChanged();
FileElsewhereCommand.NotifyCanExecuteChanged(); FileElsewhereCommand.NotifyCanExecuteChanged();
OpenInEspoCrmCommand.NotifyCanExecuteChanged(); OpenInEspoCrmCommand.NotifyCanExecuteChanged();
@@ -101,8 +125,16 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{ {
foreach (var c in value.RecentCases) RecentCases.Add(c); 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(AccountVisibility));
OnPropertyChanged(nameof(NoCasesVisibility)); OnPropertyChanged(nameof(NoCasesVisibility));
OnPropertyChanged(nameof(MultiCaseHintVisibility));
OnPropertyChanged(nameof(SingleCaseFileVisibility));
OnPropertyChanged(nameof(BackToCandidatesVisibility));
} }
public void Reset() public void Reset()
@@ -112,6 +144,7 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
Match = null; Match = null;
ErrorMessage = null; ErrorMessage = null;
AmbiguousMessage = null; AmbiguousMessage = null;
_ambiguousReturnTarget = null;
State = ReadingPaneState.Empty; State = ReadingPaneState.Empty;
} }
@@ -125,6 +158,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
} }
SenderEmail = senderEmail; SenderEmail = senderEmail;
// New email selection — drop any stashed candidate-list state.
_ambiguousReturnTarget = null;
State = ReadingPaneState.Loading; State = ReadingPaneState.Loading;
var cts = new CancellationTokenSource(); var cts = new CancellationTokenSource();
@@ -182,7 +217,28 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(Match!)); 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))] [RelayCommand(CanExecute = nameof(CanFileElsewhere))]
private void FileElsewhere() private void FileElsewhere()
@@ -202,12 +258,88 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
private bool CanOpenInCrm() => State == ReadingPaneState.Match && Match?.Contact != null; 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] [RelayCommand]
private void OpenManualSearch() private void OpenManualSearch()
{ {
FileRequested?.Invoke(this, new FileFromSidebarEventArgs(null)); 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; private static Visibility Vis(bool show) => show ? Visibility.Visible : Visibility.Collapsed;
} }
+62 -3
View File
@@ -62,6 +62,29 @@
<!-- Ambiguous --> <!-- Ambiguous -->
<StackPanel Visibility="{Binding AmbiguousVisibility}"> <StackPanel Visibility="{Binding AmbiguousVisibility}">
<TextBlock Text="{Binding AmbiguousMessage}" Foreground="DarkOrange" /> <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="פתח חיפוש ידני" <Button Content="פתח חיפוש ידני"
Command="{Binding OpenManualSearchCommand}" Command="{Binding OpenManualSearchCommand}"
Style="{StaticResource LinkButtonStyle}" Style="{StaticResource LinkButtonStyle}"
@@ -70,8 +93,20 @@
<!-- Match --> <!-- Match -->
<StackPanel Visibility="{Binding MatchVisibility}"> <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 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 Text="{Binding Match.Contact.EmailAddress}" Foreground="Gray" />
<TextBlock Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" /> <TextBlock Style="{StaticResource LabelStyle}" Text="לקוח/חברה" Visibility="{Binding AccountVisibility}" />
@@ -82,14 +117,33 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate> <DataTemplate>
<Border BorderBrush="#EEE" BorderThickness="0,0,0,1" Padding="0,4"> <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> <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="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" /> <Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</Hyperlink>
</TextBlock> </TextBlock>
<TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}" <TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}"
Foreground="Gray" FontSize="10" /> Foreground="Gray" FontSize="10" />
</StackPanel> </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> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -110,7 +164,12 @@
<Button Content="תייק לתיק הזה" <Button Content="תייק לתיק הזה"
Command="{Binding FileToThisCommand}" Command="{Binding FileToThisCommand}"
Padding="8,4" Margin="0,0,0,4" 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="תייק למקום אחר…" <Button Content="תייק למקום אחר…"
Command="{Binding FileElsewhereCommand}" Command="{Binding FileElsewhereCommand}"
Padding="8,4" Margin="0,0,0,4" 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, /// current Explorer selection, opens the search dialog, files on success,
/// and stamps categories on the chosen MailItems. /// and stamps categories on the chosen MailItems.
/// </summary> /// </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) if (!IsConfigured)
{ {
@@ -673,6 +680,16 @@ namespace OutlookAddin.Services
try try
{ {
var envelopes = mailItems.Select(MailItemExtractor.Extract).ToList(); 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 firstEnvelope = envelopes[0];
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger) var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
@@ -689,7 +706,7 @@ namespace OutlookAddin.Services
ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType); ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType);
if (ok != true) return; if (ok != true) return;
var target = vm.GetSelectedFilingTarget(); target = vm.GetSelectedFilingTarget();
if (target == null) if (target == null)
{ {
Logger.Warning( Logger.Warning(
@@ -706,6 +723,7 @@ namespace OutlookAddin.Services
Logger.Information( Logger.Information(
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})", "Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
envelopes.Count, target.ParentType, target.ParentId, target.ParentName); envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
}
var summary = await FilingService!.FileAsync(envelopes, target).ConfigureAwait(true); 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() private List<Outlook.MailItem> ReadCurrentSelection()
{ {
var result = new List<Outlook.MailItem>(); var result = new List<Outlook.MailItem>();
@@ -109,7 +109,7 @@ namespace OutlookAddin.Services
vm.FileRequested += async (s, e) => 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"); } catch (Exception ex) { _logger.Warning(ex, "Sidebar file-request handler failed"); }
}; };
vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url); vm.OpenInBrowserRequested += (s, url) => TryOpenInBrowser(url);