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; }
|
||||
|
||||
/// <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,60 +45,51 @@ 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)
|
||||
{
|
||||
var ambiguous = new MatchResult
|
||||
{
|
||||
SenderEmail = senderEmail,
|
||||
IsAmbiguous = true,
|
||||
CandidateCount = matches.Count
|
||||
};
|
||||
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
|
||||
return ambiguous;
|
||||
_cache.Set(key, (MatchResult?)null, CacheExpiry);
|
||||
return null;
|
||||
}
|
||||
|
||||
var contactRef = matches[0];
|
||||
var contact = await _client.GetContactAsync(contactRef.Id, ContactSelect, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AccountEntity? account = null;
|
||||
if (!string.IsNullOrWhiteSpace(contact.AccountId))
|
||||
if (distinctIds.Count == 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for {Email}", contact.AccountId, senderEmail);
|
||||
}
|
||||
var single = await LoadContactBundleAsync(distinctIds[0], senderEmail, cancellationToken).ConfigureAwait(false);
|
||||
_cache.Set<MatchResult?>(key, single, CacheExpiry);
|
||||
return single;
|
||||
}
|
||||
|
||||
var cases = new System.Collections.Generic.List<CaseEntity>();
|
||||
try
|
||||
{
|
||||
// Pull up to 50 cases per contact so the sidebar can show the
|
||||
// whole list, not just the 5 most recent. 50 is well below the
|
||||
// EspoCRM default page limit and large enough for any realistic
|
||||
// attorney/contact relationship.
|
||||
var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false);
|
||||
cases.AddRange(list.List);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
|
||||
}
|
||||
// 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 result = new MatchResult
|
||||
var candidates = fetched
|
||||
.Where(c => c != null && !string.IsNullOrWhiteSpace(c!.Id))
|
||||
.Select(c => c!)
|
||||
.ToList();
|
||||
|
||||
var ambiguous = new MatchResult
|
||||
{
|
||||
SenderEmail = senderEmail,
|
||||
Contact = contact,
|
||||
Account = account,
|
||||
RecentCases = cases,
|
||||
CandidateCount = 1
|
||||
IsAmbiguous = true,
|
||||
CandidateCount = candidates.Count,
|
||||
Candidates = candidates
|
||||
};
|
||||
|
||||
_cache.Set<MatchResult?>(key, result, CacheExpiry);
|
||||
return result;
|
||||
_cache.Set<MatchResult?>(key, ambiguous, CacheExpiry);
|
||||
return ambiguous;
|
||||
}
|
||||
catch (EspoCrmAuthorizationException)
|
||||
{
|
||||
@@ -114,6 +106,86 @@ namespace MarcusLaw.OutlookAddin.Core.Services
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
try
|
||||
{
|
||||
account = await _client.GetAccountAsync(contact.AccountId!, "id,name,emailAddress", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "MatchingService: failed to load account {AccountId} for contact {ContactId}", contact.AccountId, contactId);
|
||||
}
|
||||
}
|
||||
|
||||
var cases = new System.Collections.Generic.List<CaseEntity>();
|
||||
try
|
||||
{
|
||||
// Pull up to 50 cases per contact so the sidebar can show the
|
||||
// whole list, not just the 5 most recent. 50 is well below the
|
||||
// EspoCRM default page limit and large enough for any realistic
|
||||
// attorney/contact relationship.
|
||||
var list = await _client.ListCasesForContactAsync(contact.Id, 50, cancellationToken).ConfigureAwait(false);
|
||||
cases.AddRange(list.List);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "MatchingService: failed to list cases for {ContactId}", contact.Id);
|
||||
}
|
||||
|
||||
return new MatchResult
|
||||
{
|
||||
SenderEmail = senderEmail,
|
||||
Contact = contact,
|
||||
Account = account,
|
||||
RecentCases = cases,
|
||||
CandidateCount = 1
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<ContactEntity?> SafeGetContactAsync(string contactId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _client.GetContactAsync(contactId, ContactSelect, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (EspoCrmAuthorizationException) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning(ex, "MatchingService: candidate contact fetch failed for {Id}", contactId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Invalidate(string senderEmail)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(senderEmail)) return;
|
||||
|
||||
@@ -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,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>
|
||||
<TextBlock>
|
||||
<Run Text="["/><Run Text="{Binding Number, Mode=OneWay}"/><Run Text="] " />
|
||||
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Status, Converter={StaticResource HebrewStatus}}"
|
||||
Foreground="Gray" FontSize="10" />
|
||||
</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"
|
||||
|
||||
@@ -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,39 +680,50 @@ namespace OutlookAddin.Services
|
||||
try
|
||||
{
|
||||
var envelopes = mailItems.Select(MailItemExtractor.Extract).ToList();
|
||||
var firstEnvelope = envelopes[0];
|
||||
|
||||
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
|
||||
FilingTarget? target = TryResolveSidebarAutoTarget(preselectedMatch);
|
||||
if (target != null)
|
||||
{
|
||||
SenderEmail = firstEnvelope.From,
|
||||
SenderName = TryGetSenderName(mailItems[0])
|
||||
};
|
||||
|
||||
var dialog = new FileToCaseDialog(vm);
|
||||
AttachOwnerToOutlook(dialog);
|
||||
var ok = dialog.ShowDialog();
|
||||
Logger.Information(
|
||||
"FileToCase dialog closed (ok={Ok}, selected id={Id}, entityType={Type})",
|
||||
ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType);
|
||||
if (ok != true) return;
|
||||
|
||||
var target = vm.GetSelectedFilingTarget();
|
||||
if (target == null)
|
||||
{
|
||||
Logger.Warning(
|
||||
"Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})",
|
||||
vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType);
|
||||
MessageBox.Show(
|
||||
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
|
||||
"Marcus-Law OutlookAddin",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
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];
|
||||
|
||||
Logger.Information(
|
||||
"Filing {Count} mail item(s) to {Type}/{Id} ({Name})",
|
||||
envelopes.Count, target.ParentType, target.ParentId, target.ParentName);
|
||||
var vm = new FileToCaseViewModel(CrmClient!, Settings, Logger)
|
||||
{
|
||||
SenderEmail = firstEnvelope.From,
|
||||
SenderName = TryGetSenderName(mailItems[0])
|
||||
};
|
||||
|
||||
var dialog = new FileToCaseDialog(vm);
|
||||
AttachOwnerToOutlook(dialog);
|
||||
var ok = dialog.ShowDialog();
|
||||
Logger.Information(
|
||||
"FileToCase dialog closed (ok={Ok}, selected id={Id}, entityType={Type})",
|
||||
ok, vm.SelectedTarget?.Id, vm.SelectedTarget?.EntityType);
|
||||
if (ok != true) return;
|
||||
|
||||
target = vm.GetSelectedFilingTarget();
|
||||
if (target == null)
|
||||
{
|
||||
Logger.Warning(
|
||||
"Filing skipped — selected EspoEntityRef has no usable Id/EntityType (id={Id}, name={Name}, type={Type})",
|
||||
vm.SelectedTarget?.Id, vm.SelectedTarget?.Name, vm.SelectedTarget?.EntityType);
|
||||
MessageBox.Show(
|
||||
"התיק שנבחר לא הוחזר עם סוג ישות (entityType) תקין. ייתכן ש-Klear שלך מחזיר שדה אחר; הלוג מכיל את הפרטים. שלח לאדמין.",
|
||||
"Marcus-Law OutlookAddin",
|
||||
MessageBoxButton.OK,
|
||||
MessageBoxImage.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user