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:
@@ -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,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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user