b6624a924f
Replace the single-line ListBox with a DataGrid (case number, name,
court case number, status) so users disambiguate same-named cases
(e.g. two "שלומוב זויה" cases). Hydrates each search hit via
GET /Case/{id}?select=id,name,number,status,cCourtCaseNumber in
parallel so columns populate without an extra click. CourtCaseNumber
is mapped to the EspoCRM custom field cCourtCaseNumber.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
343 lines
13 KiB
C#
343 lines
13 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Collections.ObjectModel;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using MarcusLaw.OutlookAddin.Core.Models;
|
||
using MarcusLaw.OutlookAddin.Core.Services;
|
||
using Serilog;
|
||
|
||
namespace MarcusLaw.OutlookAddin.UI.ViewModels
|
||
{
|
||
/// <summary>
|
||
/// Picks a Case + optional subfolder (filesystem-style, via Marcus-Law's
|
||
/// NetworkStorageIntegration) for saving raw attachment files.
|
||
/// </summary>
|
||
public sealed partial class SaveAttachmentsViewModel : ObservableObject
|
||
{
|
||
// Default subfolders the integration creates under each Case folder.
|
||
// Mirrors Resources/metadata/app/networkStorage.json on the server.
|
||
private static readonly string[] DefaultCaseSubfolders =
|
||
{
|
||
"מסמכים",
|
||
"אסמכתאות",
|
||
"התכתבויות",
|
||
"כללי"
|
||
};
|
||
|
||
private const string RootSentinel = "(שורש התיק)";
|
||
|
||
private readonly IEspoCrmClient _client;
|
||
private readonly ILogger _logger;
|
||
private CancellationTokenSource? _searchCts;
|
||
|
||
[ObservableProperty]
|
||
private string searchText = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private CaseEntity? selectedCase;
|
||
|
||
[ObservableProperty]
|
||
private string? selectedSubfolder;
|
||
|
||
[ObservableProperty]
|
||
private string? statusMessage;
|
||
|
||
/// <summary>
|
||
/// Resolved server-side case folder path for the currently
|
||
/// selected case (e.g. "47-אריאל מונאס"). Populated by
|
||
/// <see cref="RefreshSubfoldersForSelectedCaseAsync"/> after the
|
||
/// case + primary contact have been fetched. Empty until then.
|
||
/// </summary>
|
||
public string? ResolvedCaseFolderPath { get; private set; }
|
||
|
||
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
|
||
|
||
public ObservableCollection<string> Subfolders { get; } = new ObservableCollection<string>();
|
||
|
||
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new ObservableCollection<AttachmentRowViewModel>();
|
||
|
||
public bool? DialogResult { get; private set; }
|
||
|
||
public IReadOnlyList<EspoAttachment> SelectedAttachments
|
||
{
|
||
get
|
||
{
|
||
var list = new List<EspoAttachment>();
|
||
foreach (var row in Attachments)
|
||
{
|
||
if (row.IsSelected) list.Add(row.Data);
|
||
}
|
||
return list;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Subfolder string the user picked, or null to save to the case
|
||
/// folder root.
|
||
/// </summary>
|
||
public string? ChosenSubfolderName =>
|
||
string.IsNullOrEmpty(SelectedSubfolder) ||
|
||
string.Equals(SelectedSubfolder, RootSentinel, StringComparison.Ordinal)
|
||
? null
|
||
: SelectedSubfolder;
|
||
|
||
public CaseEntity? PickedCase => SelectedCase;
|
||
|
||
public event EventHandler? RequestClose;
|
||
|
||
public SaveAttachmentsViewModel(
|
||
IEspoCrmClient client,
|
||
ILogger logger,
|
||
IEnumerable<EspoAttachment> attachments)
|
||
{
|
||
_client = client ?? throw new ArgumentNullException(nameof(client));
|
||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||
|
||
ResetSubfolders();
|
||
|
||
foreach (var att in attachments)
|
||
{
|
||
Attachments.Add(new AttachmentRowViewModel(att));
|
||
}
|
||
}
|
||
|
||
private void ResetSubfolders()
|
||
{
|
||
Subfolders.Clear();
|
||
Subfolders.Add(RootSentinel);
|
||
foreach (var f in DefaultCaseSubfolders) Subfolders.Add(f);
|
||
SelectedSubfolder = Subfolders[0];
|
||
}
|
||
|
||
partial void OnSearchTextChanged(string value)
|
||
{
|
||
_searchCts?.Cancel();
|
||
_searchCts = new CancellationTokenSource();
|
||
_ = SearchAsync(value, _searchCts.Token);
|
||
}
|
||
|
||
partial void OnSelectedCaseChanged(CaseEntity? value)
|
||
{
|
||
SubmitCommand.NotifyCanExecuteChanged();
|
||
_ = RefreshSubfoldersForSelectedCaseAsync();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refreshes the subfolder dropdown to merge static defaults with
|
||
/// the actual folders that already exist on the network share for
|
||
/// the chosen case. Also resolves <see cref="ResolvedCaseFolderPath"/>
|
||
/// using the same logic as the server (primary contact's name, not
|
||
/// the case's lawsuit title).
|
||
/// </summary>
|
||
public async Task RefreshSubfoldersForSelectedCaseAsync()
|
||
{
|
||
ResetSubfolders();
|
||
ResolvedCaseFolderPath = null;
|
||
|
||
var c = SelectedCase;
|
||
if (c == null || string.IsNullOrEmpty(c.Id)) return;
|
||
|
||
try { await _client.EnsureCaseSubfoldersAsync(c.Id).ConfigureAwait(true); }
|
||
catch (Exception ex) { _logger.Warning(ex, "EnsureCaseSubfolders failed; subfolders may be missing on disk"); }
|
||
|
||
string? primaryContactName = null;
|
||
try
|
||
{
|
||
var detail = await _client.GetCaseAsync(
|
||
c.Id, "id,name,number,contactsIds,networkStorageFolderPath", default).ConfigureAwait(true);
|
||
c.Number = detail.Number;
|
||
c.Name = detail.Name;
|
||
c.NetworkStorageFolderPath = detail.NetworkStorageFolderPath;
|
||
c.ContactsIds = detail.ContactsIds ?? new List<string>();
|
||
|
||
if (c.ContactsIds.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
var contact = await _client.GetContactAsync(c.ContactsIds[0], "id,name", default).ConfigureAwait(true);
|
||
primaryContactName = contact?.Name;
|
||
}
|
||
catch (Exception ex) { _logger.Warning(ex, "Primary contact fetch failed for case {CaseId}", c.Id); }
|
||
}
|
||
}
|
||
catch (Exception ex) { _logger.Warning(ex, "GetCase for subfolder list failed"); }
|
||
|
||
var folderPath = ResolveCaseFolderPath(c, primaryContactName);
|
||
ResolvedCaseFolderPath = folderPath;
|
||
_logger.Information(
|
||
"Resolved case folder for {CaseId} (number={Number}, contact={Contact}, stored={Stored}) → {Path}",
|
||
c.Id, c.Number ?? "(none)", primaryContactName ?? "(none)", c.NetworkStorageFolderPath ?? "(none)", folderPath);
|
||
if (string.IsNullOrEmpty(folderPath)) return;
|
||
|
||
try
|
||
{
|
||
var items = await _client.ListNetworkStorageFolderAsync(folderPath).ConfigureAwait(true);
|
||
foreach (var item in items)
|
||
{
|
||
if (!item.IsFolder) continue;
|
||
if (Subfolders.Contains(item.Name)) continue;
|
||
if (string.Equals(item.Name, RootSentinel, StringComparison.Ordinal)) continue;
|
||
Subfolders.Add(item.Name);
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warning(ex, "Listing subfolders for case {CaseId} failed", c.Id);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mirrors NetworkDocumentService::buildEntityFolderName for Case
|
||
/// on the server side. Fallback order:
|
||
/// 1) Case.networkStorageFolderPath (if the integration's hook
|
||
/// has set it — most authoritative)
|
||
/// 2) "<number>-<primary contact name>" ← server's recipe
|
||
/// 3) "<number>-<case lawsuit name>"
|
||
/// 4) contact / case name on its own
|
||
/// </summary>
|
||
public static string ResolveCaseFolderPath(CaseEntity c, string? primaryContactName = null)
|
||
{
|
||
if (c == null) return string.Empty;
|
||
if (!string.IsNullOrWhiteSpace(c.NetworkStorageFolderPath))
|
||
{
|
||
return c.NetworkStorageFolderPath!.TrimEnd('/');
|
||
}
|
||
|
||
var nameForFolder = !string.IsNullOrWhiteSpace(primaryContactName)
|
||
? primaryContactName!
|
||
: (c.Name ?? string.Empty);
|
||
|
||
var raw = !string.IsNullOrWhiteSpace(c.Number)
|
||
? $"{c.Number}-{nameForFolder}"
|
||
: nameForFolder;
|
||
return SanitizeFolderSegment(raw);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mirrors LocalFilesystemClient::sanitizeFileName on the server side
|
||
/// so client-computed paths match what the integration writes.
|
||
/// </summary>
|
||
public static string SanitizeFolderSegment(string name)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(name)) return string.Empty;
|
||
foreach (var c in new[] { '׳', '״', '\'', '"', '‘', '’', '“', '”' })
|
||
{
|
||
name = name.Replace(c.ToString(), string.Empty);
|
||
}
|
||
foreach (var c in new[] { '/', '\\', ':', '*', '?', '<', '>', '|' })
|
||
{
|
||
name = name.Replace(c, '-');
|
||
}
|
||
while (name.Contains("--")) name = name.Replace("--", "-");
|
||
while (name.Contains(" ")) name = name.Replace(" ", " ");
|
||
name = name.Replace("-.", ".");
|
||
return name.Trim(' ', '-');
|
||
}
|
||
|
||
private async Task<CaseEntity?> LoadCaseRowAsync(EspoEntityRef hit, CancellationToken ct)
|
||
{
|
||
var row = new CaseEntity { Id = hit.Id, Name = hit.Name };
|
||
if (string.IsNullOrEmpty(hit.Id)) return row;
|
||
try
|
||
{
|
||
var detail = await _client.GetCaseAsync(
|
||
hit.Id, "id,name,number,status,cCourtCaseNumber", ct).ConfigureAwait(false);
|
||
row.Number = detail.Number;
|
||
row.Status = detail.Status;
|
||
row.CourtCaseNumber = detail.CourtCaseNumber;
|
||
if (!string.IsNullOrEmpty(detail.Name)) row.Name = detail.Name;
|
||
}
|
||
catch (OperationCanceledException) { throw; }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warning(ex, "Hydrating case {CaseId} for picker failed", hit.Id);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
private async Task SearchAsync(string query, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(250, ct).ConfigureAwait(true);
|
||
|
||
var trimmed = (query ?? string.Empty).Trim();
|
||
if (trimmed.Length < 2)
|
||
{
|
||
Cases.Clear();
|
||
StatusMessage = null;
|
||
return;
|
||
}
|
||
|
||
var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
Cases.Clear();
|
||
var caseHits = new List<EspoEntityRef>();
|
||
foreach (var hit in search.List)
|
||
{
|
||
if (string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase))
|
||
caseHits.Add(hit);
|
||
}
|
||
|
||
if (caseHits.Count == 0)
|
||
{
|
||
StatusMessage = "אין תוצאות מסוג תיק (Case)";
|
||
return;
|
||
}
|
||
|
||
var detailTasks = new List<Task<CaseEntity?>>(caseHits.Count);
|
||
foreach (var hit in caseHits)
|
||
{
|
||
detailTasks.Add(LoadCaseRowAsync(hit, ct));
|
||
}
|
||
var details = await Task.WhenAll(detailTasks).ConfigureAwait(true);
|
||
ct.ThrowIfCancellationRequested();
|
||
|
||
foreach (var row in details)
|
||
{
|
||
if (row != null) Cases.Add(row);
|
||
}
|
||
StatusMessage = $"נמצאו {Cases.Count} תיקים";
|
||
}
|
||
catch (OperationCanceledException) { /* superseded */ }
|
||
catch (EspoCrmAuthorizationException)
|
||
{
|
||
StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.Warning(ex, "SaveAttachments search failed");
|
||
StatusMessage = "שגיאת חיפוש: " + ex.Message;
|
||
}
|
||
}
|
||
|
||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||
private void Submit()
|
||
{
|
||
DialogResult = true;
|
||
RequestClose?.Invoke(this, EventArgs.Empty);
|
||
}
|
||
|
||
private bool CanSubmit()
|
||
{
|
||
if (SelectedCase == null || string.IsNullOrWhiteSpace(SelectedCase.Id)) return false;
|
||
foreach (var row in Attachments)
|
||
{
|
||
if (row.IsSelected) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
[RelayCommand]
|
||
private void Cancel()
|
||
{
|
||
DialogResult = false;
|
||
RequestClose?.Invoke(this, EventArgs.Empty);
|
||
}
|
||
}
|
||
}
|