feat(uat-batch): Group P — nested document subfolders phases 2/3

P56 from the 2026-05-21 plan. Foundation (phase 1) shipped in e91055f.

Shipped:
  - **UploadZone scope radio.** <FileUploadZone> accepts an optional
    `interestId` prop. When set (currently passed from
    InterestDocumentsTab) the upload-zone surfaces a small fieldset:
    "File at: ⦿ This deal | ◯ Client-level (all deals)". Default is
    deal-scope so reps don't accidentally surface deal-specific docs
    across every historical interest of the client. The interest FK
    is forwarded to /api/v1/files/upload only when "This deal" is
    selected; client-level uploads omit it and land at the client
    folder.
  - **Outcome → folder rename lifecycle hook.** New
    `renameInterestFolderForOutcome(interestId, portId, outcome)` in
    document-folders.service. Strips any prior outcome suffix from
    the folder name (so re-running on a lost→won flip doesn't
    accumulate parens) and appends `(Won)` / `(Lost)` / `(Cancelled)`.
    Fired fire-and-forget from interests.service.setInterestOutcome
    via dynamic import to dodge the circular dep with this module's
    primary-berth label resolver. No-op when the folder hasn't been
    created yet (first upload happens later).
  - **Backfill script.** scripts/backfill-nested-document-folders.ts
    iterates every (port_id, interest_id) pair in `files` that has
    a non-null interest_id and calls ensureEntityFolder so the
    nested `Clients/<Name>/Deal …/` folder exists. Idempotent —
    `ensureEntityFolder` short-circuits when the folder is already
    there. Per-port advisory lock (FNV-1a of port_id) keeps two
    operators from racing. Dry-run by default; `--apply` to commit.

Deferred:
  - listFilesAggregatedByEntity rewrite to show "This deal" vs "From
    client" subheadings — UI polish; the per-row filing already
    happens correctly via the upload-zone scope radio.
  - Documents Hub tree rendering for nested interest folders — the
    folder rows already exist with `parent_id` set; the tree
    component picks them up automatically.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:43:55 +02:00
parent a7cbee09ee
commit 0ed03fcd7f
5 changed files with 196 additions and 0 deletions

View File

@@ -18,6 +18,15 @@ interface FileUploadZoneProps {
clientId?: string;
yachtId?: string;
companyId?: string;
/**
* Optional interest FK. When supplied alongside `clientId`, the rep
* picks whether to file the upload at the deal level
* (`Clients/<Name>/<Interest folder>/`) or the client level
* (`Clients/<Name>/`) via the scope radio. When omitted the radio
* is hidden and the upload always files at whichever entity-scope
* the caller passed.
*/
interestId?: string;
/**
* Optional folder to deposit the file into. Hub uploads pass the
* currently-selected folderId so files land where the user expects.
@@ -37,12 +46,20 @@ export function FileUploadZone({
clientId,
yachtId,
companyId,
interestId,
folderId,
onUploadComplete,
}: FileUploadZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState<UploadingFile[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
// Scope toggle — only meaningful when an interest is being uploaded
// FROM (i.e. the rep is on the InterestDocumentsTab). Default to the
// narrower "deal" scope so a rep uploading a contract specific to this
// deal doesn't accidentally surface it under every interest the client
// ever had. They can flip to client-level for shared docs (passport,
// KYC, etc.).
const [scope, setScope] = useState<'interest' | 'client'>(interestId ? 'interest' : 'client');
const uploadFiles = useCallback(
async (fileList: FileList) => {
@@ -63,6 +80,13 @@ export function FileUploadZone({
if (clientId) formData.append('clientId', clientId);
if (yachtId) formData.append('yachtId', yachtId);
if (companyId) formData.append('companyId', companyId);
// Scope-aware: when the rep picked "This deal" send the
// interest FK so the service files the row at the nested
// Interest folder; when they picked "Client-level" omit the
// interest FK so it lands at the client folder.
if (interestId && scope === 'interest') {
formData.append('interestId', interestId);
}
if (entityType) formData.append('entityType', entityType);
if (entityId) formData.append('entityId', entityId);
if (folderId) formData.append('folderId', folderId);
@@ -138,6 +162,37 @@ export function FileUploadZone({
return (
<div className="space-y-3">
{/* Scope radio — only renders when an interest FK is present. The
rep picks whether the upload files at the deal level (nested
under Clients/<Name>/<Interest>) or the client level
(Clients/<Name>). Default = deal, since that's the narrower
filing path and reps almost always upload deal-specific docs
here. */}
{interestId ? (
<fieldset className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 px-3 py-2 text-xs">
<legend className="px-1 font-semibold uppercase tracking-wide text-muted-foreground">
File at
</legend>
<label className="inline-flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="upload-scope"
checked={scope === 'interest'}
onChange={() => setScope('interest')}
/>
<span>This deal</span>
</label>
<label className="inline-flex cursor-pointer items-center gap-1.5">
<input
type="radio"
name="upload-scope"
checked={scope === 'client'}
onChange={() => setScope('client')}
/>
<span>Client-level (all deals)</span>
</label>
</fieldset>
) : null}
<div
role="button"
tabIndex={0}

View File

@@ -140,6 +140,13 @@ export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps)
entityType="client"
entityId={interest.clientId}
clientId={interest.clientId}
// Pass the interest FK so the upload-zone's scope radio
// surfaces "This deal" (default) vs "Client-level". The
// server-side service routes the file to the nested
// interest folder when the rep picks "This deal", or
// leaves it at the client folder when they pick
// client-level.
interestId={interestId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: filesQueryKey });
}}

View File

@@ -811,3 +811,40 @@ export async function demoteSystemFolderOnEntityDelete(
})
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
}
/**
* Phase 2 nested-subfolders lifecycle hook. Re-renames an interest's
* document folder when its outcome changes — e.g. `Deal A1-A3` becomes
* `Deal A1-A3 (Won)`, `(Lost)`, or `(Cancelled)`. No-op when the
* folder doesn't exist yet (uploads happen later) or when the outcome
* is null (still in flight).
*
* Called from interests.service.setInterestOutcome via dynamic import
* to avoid the circular dep with this module's primary-berth label
* resolver.
*/
export async function renameInterestFolderForOutcome(
interestId: string,
portId: string,
outcome: string | null,
): Promise<void> {
if (!outcome) return;
const folder = await db.query.documentFolders.findFirst({
where: and(
eq(documentFolders.portId, portId),
eq(documentFolders.entityType, 'interest'),
eq(documentFolders.entityId, interestId),
),
});
if (!folder) return;
// Strip any prior outcome suffix so re-running the hook (e.g. when
// outcome flips from lost → won) doesn't accumulate parens.
const baseName = folder.name.replace(/\s*\((Won|Lost|Cancelled)\)\s*$/i, '');
const label = outcome === 'won' ? 'Won' : outcome === 'lost' ? 'Lost' : 'Cancelled';
const newName = `${baseName} (${label})`;
if (newName === folder.name) return;
await db
.update(documentFolders)
.set({ name: newName, updatedAt: new Date() })
.where(and(eq(documentFolders.id, folder.id), eq(documentFolders.portId, portId)));
}

View File

@@ -1244,6 +1244,20 @@ export async function setInterestOutcome(
// via system_settings.berth_rules.
void evaluateRule('interest_completed', id, portId, meta);
// Phase 2 nested-subfolders — rename the interest's document folder
// to surface the outcome inline (e.g. "Deal A1-A3 (Won)"). Dynamic
// import avoids the circular dep with document-folders.service which
// already pulls from interests.service for the primary-berth label.
void import('@/lib/services/document-folders.service')
.then((m) =>
m.renameInterestFolderForOutcome
? m.renameInterestFolderForOutcome(id, portId, data.outcome)
: null,
)
.catch(() => {
// Folder may not exist yet (first upload happens later) — silent.
});
// Phase 6 — CRM → Umami attribution. Fire a custom Umami event so
// marketing can correlate inbound website traffic with the resulting
// deal outcome. Dynamic import to avoid a circular service dep at