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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user