feat(documents): universal upload-with-fields UI wiring (B3 #11)
Backend foundations were already in place ('generic' CustomDocumentType,
storage-path routing). This wires the UI surface across Documents Hub +
entity file tabs.
- UploadForSigningDialog: interestId now string | null; new entity?,
folderId?, onCreated? props. Generic path POSTs to new endpoint
/api/v1/upload-for-signing; interest-scoped paths unchanged.
- uploadDocumentForSigning service: interestId nullable; skips interest
lookup, pipeline-stage advance, doc-status flip on the generic path.
Routes file FK + auto-filed folder via either interest.clientId or the
caller-supplied entity. Validation enforces the matching invariant
(generic must be interestId=null, type-specific must carry one).
- New menu item in NewDocumentMenu ("Upload & send for signature") on
Documents Hub root + folder views.
- Upload & send-for-signature button on ClientFilesTab + CompanyFilesTab,
gated by documents.send_for_signing.
Existing unit tests for the service still pass (validation paths unchanged).
This commit is contained in:
@@ -688,6 +688,7 @@ _New UI surfaces, new endpoints, schema migrations, multi-step flows._
|
||||
10. **Comprehensive admin-settings IA audit + regroup** — _src/app/(dashboard)/[portSlug]/admin/_ — 41 admin pages today, organically grown: `ai`, `audit`, `backup`, `berths/bulk-add`, `berths/reconcile`, `branding`, `brochures`, `custom-fields`, `documenso`, `duplicates`, `email-templates`, `email`, `errors`, `forms`, `import`, `inquiries`, `invitations`, `monitoring`, `ocr`, `onboarding`, `pipeline-rules`, `ports`, `pulse`, `qualification-criteria`, `reminders`, `reports`, `residential-stages`, `roles`, `sends`, `settings`, `storage`, `tags`, `templates`, `users`, `vocabularies`, `webhooks`, `website-analytics`. Settings are scattered — e.g. test-email lives on Branding, SMTP test on Email, password-reset copy probably in `email-templates`, but the rep has to guess. Audit each page for: (a) what settings live there now, (b) which settings logically belong elsewhere ("right home" test — Documenso send mode currently lives on Documenso, makes sense; per-port email signature would make more sense under Branding than Email), (c) duplicates (vocabularies vs custom-fields vs qualification-criteria overlap on enum tuning). Then propose a regrouped IA — likely fewer top-level pages with clear domain headers (Configuration → Branding, Email, Documenso, Storage, Webhooks; Workflows → Pipeline rules, Reminders, Auto-stage advancement; Catalog → Vocabularies, Tags, Custom fields, Qualification criteria; Operations → Monitoring, Pulse, Audit log, Errors, Backup; Data → Import, Duplicates, Bulk berth tools; Identity → Users, Roles, Invitations, Onboarding). Pair with a new admin index page that groups by domain instead of a flat alphabetical list. Effort: ~1.5-2 days — audit pass + IA proposal review + actual file moves + nav updates + redirect shims for old URLs. Captured 2026-05-22.
|
||||
- **SHIPPED in this session (Phase 1 + Phase 2):** Full audit + proposal at `docs/admin-ia-proposal.md`. Final IA = 7 domains, 38 pages (down from 41 via three deletes). `admin-sections-browser.tsx` rewritten to the new domain shape (Brand & Communication, Sales workflow, Catalog, Identity & access, Inbox & data quality, Integrations, System & observability). Deleted with redirects: `/admin/ocr` → `/admin/ai`, `/admin/reports` → `/[portSlug]/dashboard`, `/admin/invitations` → `/admin/users` (this last one was already a redirect). Renamed: "Documenso & EOI" → "Signing service (Documenso)". New: `/admin/berths` index page surfacing bulk-add + reconcile sub-tools (which were previously discoverable only via deep links). `<EmailPreviewCard>` on Branding cross-links to `/admin/email` per-template tester. Search-nav-catalog updated (ocr entry removed, berths entry added). tsc clean.
|
||||
11. **B3 #9 follow-up — UI wiring for universal upload-with-fields** — _src/components/documents/upload-for-signing-dialog.tsx_ (`<FieldPlacementStep>` lives inside this monolith — needs extraction into a standalone component the other upload modals can mount conditionally), _src/components/documents/new-document-menu.tsx_ + _src/components/documents/documents-hub.tsx_ + _src/components/files/file-upload-zone.tsx_ + entity-tab upload sites (client/yacht/company doc tabs). **Backend foundations SHIPPED 2026-05-22**: `CustomDocumentType` union now includes `'generic'`; `uploadDocumentForSigning` skips pipeline-stage advance + doc-status flip when generic; route validator accepts the new value; storage path category routes to `signed-source/`. **UI half deferred** to a paired session — needs careful surgery to each upload modal to add the "Send for signature?" toggle + mount the extracted field-placement step. Effort for UI wiring: ~5-7h. Captured 2026-05-22.
|
||||
- **SHIPPED in this session:** `UploadForSigningDialog` now accepts `interestId: string | null`, `entity?: { type, id }`, `folderId?` and `onCreated?` callback. When `interestId` is null + `documentType='generic'`, the dialog POSTs to a new generic endpoint `/api/v1/upload-for-signing` instead of the interest-scoped one. The service was refactored to accept `interestId: string | null` and an optional `entity` arg, skips the pipeline-stage advance + doc-status flip + interest lookup on the generic path, and routes the file row's FK + auto-filed folder via either the interest's client or the caller-supplied entity. New menu item in `NewDocumentMenu` ("Upload & send for signature") appears on both Documents Hub root + folder views; new buttons under `FileUploadZone` on `ClientFilesTab` + `CompanyFilesTab`. Permission gated by `documents.send_for_signing`. Service-level validation enforces the invariant that generic-type uploads MUST come without interestId and vice-versa.
|
||||
12. **Time-period PDF report + chart rendering + deeper data** — _src/lib/pdf/reports/dashboard-report.tsx_, _src/lib/services/dashboard-report-data.service.ts_, _src/lib/pdf/reports/types.ts_, new _src/lib/pdf/reports/charts.tsx_, _src/components/reports/export-dashboard-pdf-button.tsx_ (date-range picker). Today's PDF report ignores dateFrom/dateTo for most sections and renders every chart-style widget as a table. User wants: (a) **time-range filter** that scopes EVERY section to a chosen window — new clients in the window, new interests in the window, active interests touching the window, in-progress berths (sold/under-offer transitions in the window), pipeline counts at the start vs end of window, etc.; (b) **chart rendering** — react-pdf supports SVG, so build small SVG generators (`<PipelineFunnel data>`, `<OccupancyTimeline data>`, `<SourceMixDonut data>`) inline OR pre-render via vega-lite/d3-node to PNG and embed; (c) **deeper data per section** — add berths-in-flight (status changes within window), client+interest cohort tables, contact-cadence histogram, document-signing throughput. Shape: extend `DashboardReportData` with `window: {from, to}` and new sub-sections; extend the export-PDF dialog to take a date-range; route handler propagates the window to every per-section resolver. Effort: ~8-12h depending on chart-rendering approach (inline SVG is ~6h, vega-lite pre-render is ~10h with a worker round-trip). Captured 2026-05-22.
|
||||
- **SHIPPED in this session:** Catalog expanded from 5 ids to 25 — chart variants (pipeline funnel bar, berth status donut, source conversion bar, lead source donut, occupancy timeline line) + period cohorts (new clients/interests, berths sold, deposits received, documents/contracts signed) + value views (pipeline value breakdown, revenue forecast, avg sales cycle, berth demand, country distribution, deal pulse distribution, recent activity). Hand-rolled SVG chart primitives in `src/lib/pdf/reports/charts.tsx` (HorizontalBarChart, DonutChart, LineChart) using @react-pdf/renderer's native Svg/Path/Rect support. Export-dialog grew a date-range picker with Last-30/90-days quick presets, defaults to last 30 days. Route + service plumbing carries dateFrom/dateTo. 11 of 16 pending resolvers landed (new_clients_period, new_interests_period, berths_sold_period via audit log, deposits_received_period, signed_documents_period, contracts_signed_period, berth_demand_ranking, lead_source_donut, client_country_distribution, recent_activity, pipeline_value_breakdown, revenue_forecast, avg_sales_cycle). Still pending (in this session's PENDING_RESOLVER_IDS set): stage_conversion_rates, occupancy_timeline_chart (needs daily buckets), inquiry_inbox_summary, reminders_summary, deal_pulse_distribution (requires the pulse service's dynamic computation, not a simple column query — left as follow-up). Also shipped: PDF logo absolutize for server-side fetch (was empty because @react-pdf/renderer can't fetch path-only URLs server-side), "Dashboard report" → "Report" default name, section-orphan fix (`wrap={false}` + `minPresenceAhead`).
|
||||
|
||||
|
||||
160
src/app/api/v1/upload-for-signing/route.ts
Normal file
160
src/app/api/v1/upload-for-signing/route.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import {
|
||||
uploadDocumentForSigning,
|
||||
type CustomDocumentType,
|
||||
type CustomRecipientRole,
|
||||
} from '@/lib/services/custom-document-upload.service';
|
||||
import { isPdfMagic } from '@/lib/services/berth-pdf-parser';
|
||||
|
||||
/**
|
||||
* Generic upload-for-signing endpoint — used by the Documents Hub and
|
||||
* entity doc-tab "Send file for signature" buttons where the doc isn't
|
||||
* tied to an interest's sales pipeline. The interest-scoped sibling
|
||||
* (/api/v1/interests/[id]/upload-for-signing) is still the path for
|
||||
* EOI / Contract / Reservation flows so the pipeline side effects fire.
|
||||
*
|
||||
* documentType is locked to 'generic' here. Optional `entity` +
|
||||
* `folderId` route the file to the right place on the Documents Hub.
|
||||
*
|
||||
* Permission: documents.send_for_signing — same gate as the
|
||||
* interest-scoped flow.
|
||||
*/
|
||||
|
||||
const recipientSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
role: z.enum(['SIGNER', 'APPROVER', 'CC']),
|
||||
signingOrder: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const fieldSchema = z.object({
|
||||
recipientIndex: z.number().int().nonnegative(),
|
||||
type: z.enum([
|
||||
'SIGNATURE',
|
||||
'FREE_SIGNATURE',
|
||||
'INITIALS',
|
||||
'DATE',
|
||||
'EMAIL',
|
||||
'NAME',
|
||||
'TEXT',
|
||||
'NUMBER',
|
||||
'CHECKBOX',
|
||||
'DROPDOWN',
|
||||
'RADIO',
|
||||
]),
|
||||
pageNumber: z.number().int().positive(),
|
||||
pageX: z.number().min(0).max(100),
|
||||
pageY: z.number().min(0).max(100),
|
||||
pageWidth: z.number().positive().max(100),
|
||||
pageHeight: z.number().positive().max(100),
|
||||
fieldMeta: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const entitySchema = z.object({
|
||||
type: z.enum(['client', 'company', 'yacht']),
|
||||
id: z.string().min(1),
|
||||
});
|
||||
|
||||
const MAX_PDF_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
function parseJsonField<T>(raw: unknown, schema: z.ZodType<T>, label: string): T {
|
||||
if (typeof raw !== 'string') {
|
||||
throw new ValidationError(`Missing or non-string '${label}' field`);
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new ValidationError(`'${label}' is not valid JSON`);
|
||||
}
|
||||
const result = schema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
throw new ValidationError(`'${label}' validation failed: ${result.error.issues[0]?.message}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const POST = withAuth(
|
||||
withPermission('documents', 'send_for_signing', async (req, ctx) => {
|
||||
try {
|
||||
const form = await req.formData();
|
||||
|
||||
// ─── file ──────────────────────────────────────────────────
|
||||
const file = form.get('file');
|
||||
if (!file || !(file instanceof File)) {
|
||||
throw new ValidationError('Missing file');
|
||||
}
|
||||
if (file.size > MAX_PDF_BYTES) {
|
||||
throw new ValidationError(`File exceeds ${MAX_PDF_BYTES / 1024 / 1024} MB cap`);
|
||||
}
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
if (!isPdfMagic(buffer)) {
|
||||
throw new ValidationError('Uploaded file is not a PDF');
|
||||
}
|
||||
|
||||
// ─── scalar fields ─────────────────────────────────────────
|
||||
const title = z.string().min(1).max(255).parse(form.get('title'));
|
||||
const invitationMessageRaw = form.get('invitationMessage');
|
||||
const invitationMessage =
|
||||
typeof invitationMessageRaw === 'string'
|
||||
? z.string().max(1000).parse(invitationMessageRaw)
|
||||
: null;
|
||||
|
||||
// Optional entity / folder routing.
|
||||
const entityRaw = form.get('entity');
|
||||
const entity =
|
||||
typeof entityRaw === 'string' && entityRaw.length > 0
|
||||
? parseJsonField(entityRaw, entitySchema, 'entity')
|
||||
: null;
|
||||
const folderIdRaw = form.get('folderId');
|
||||
const folderId =
|
||||
typeof folderIdRaw === 'string' && folderIdRaw.length > 0 ? folderIdRaw : null;
|
||||
|
||||
// ─── JSON fields ───────────────────────────────────────────
|
||||
const recipients = parseJsonField(
|
||||
form.get('recipients'),
|
||||
z.array(recipientSchema).min(1).max(20),
|
||||
'recipients',
|
||||
);
|
||||
const fields = parseJsonField(
|
||||
form.get('fields'),
|
||||
z.array(fieldSchema).min(1).max(200),
|
||||
'fields',
|
||||
);
|
||||
|
||||
const result = await uploadDocumentForSigning({
|
||||
interestId: null,
|
||||
entity,
|
||||
folderId,
|
||||
portId: ctx.portId,
|
||||
portSlug: ctx.portSlug,
|
||||
documentType: 'generic' satisfies CustomDocumentType,
|
||||
title,
|
||||
pdfBuffer: buffer,
|
||||
filename: file.name || 'document.pdf',
|
||||
recipients: recipients.map((r) => ({
|
||||
name: r.name,
|
||||
email: r.email,
|
||||
role: r.role as CustomRecipientRole,
|
||||
signingOrder: r.signingOrder,
|
||||
})),
|
||||
fields,
|
||||
invitationMessage,
|
||||
meta: {
|
||||
userId: ctx.userId,
|
||||
portId: ctx.portId,
|
||||
ipAddress: ctx.ipAddress,
|
||||
userAgent: ctx.userAgent,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ data: result }, { status: 201 });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Pen } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileGrid } from '@/components/files/file-grid';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -21,6 +24,7 @@ interface ClientFilesTabProps {
|
||||
export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
||||
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
|
||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow>({
|
||||
@@ -64,12 +68,28 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
clientId={clientId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<FileUploadZone
|
||||
clientId={clientId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
|
||||
}}
|
||||
/>
|
||||
<PermissionGate resource="documents" action="send_for_signing">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUploadForSigningOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pen className="h-4 w-4" aria-hidden />
|
||||
Upload & send for signature
|
||||
</Button>
|
||||
</div>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</PermissionGate>
|
||||
|
||||
<FileGrid
|
||||
@@ -88,6 +108,17 @@ export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
|
||||
{uploadForSigningOpen && (
|
||||
<UploadForSigningDialog
|
||||
open={uploadForSigningOpen}
|
||||
onOpenChange={setUploadForSigningOpen}
|
||||
interestId={null}
|
||||
documentType="generic"
|
||||
entity={{ type: 'client', id: clientId }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDialog}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Pen } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileGrid } from '@/components/files/file-grid';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { useConfirmation } from '@/hooks/use-confirmation';
|
||||
@@ -21,6 +24,7 @@ interface CompanyFilesTabProps {
|
||||
export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
|
||||
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
|
||||
const { confirm, dialog: confirmDialog } = useConfirmation();
|
||||
|
||||
const { data, isLoading } = usePaginatedQuery<FileRow>({
|
||||
@@ -64,12 +68,28 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PermissionGate resource="files" action="upload">
|
||||
<FileUploadZone
|
||||
companyId={companyId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
<FileUploadZone
|
||||
companyId={companyId}
|
||||
onUploadComplete={() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files', { companyId }] });
|
||||
}}
|
||||
/>
|
||||
<PermissionGate resource="documents" action="send_for_signing">
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUploadForSigningOpen(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Pen className="h-4 w-4" aria-hidden />
|
||||
Upload & send for signature
|
||||
</Button>
|
||||
</div>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
</PermissionGate>
|
||||
|
||||
<FileGrid
|
||||
@@ -88,6 +108,17 @@ export function CompanyFilesTab({ companyId }: CompanyFilesTabProps) {
|
||||
fileName={previewFile?.filename}
|
||||
mimeType={previewFile?.mimeType ?? undefined}
|
||||
/>
|
||||
|
||||
{uploadForSigningOpen && (
|
||||
<UploadForSigningDialog
|
||||
open={uploadForSigningOpen}
|
||||
onOpenChange={setUploadForSigningOpen}
|
||||
interestId={null}
|
||||
documentType="generic"
|
||||
entity={{ type: 'company', id: companyId }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmDialog}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, FileSignature, Plus, Upload } from 'lucide-react';
|
||||
import { ChevronDown, FileSignature, Pen, Plus, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { FileUploadZone } from '@/components/files/file-upload-zone';
|
||||
import { UploadForSigningDialog } from '@/components/documents/upload-for-signing-dialog';
|
||||
|
||||
/**
|
||||
* Dropdown that replaces the bare "+ New document" button on the documents
|
||||
@@ -55,6 +56,7 @@ export function NewDocumentMenu({
|
||||
size = 'default',
|
||||
}: NewDocumentMenuProps) {
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [uploadForSigningOpen, setUploadForSigningOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return (
|
||||
@@ -77,6 +79,15 @@ export function NewDocumentMenu({
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => setUploadForSigningOpen(true)} className="gap-2 py-2.5">
|
||||
<Pen className="h-4 w-4" aria-hidden />
|
||||
<div className="flex flex-col">
|
||||
<span>Upload & send for signature</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Drop a PDF, place fields, send via Documenso
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild className="gap-2 py-2.5">
|
||||
<Link href={`/${portSlug}/documents/new`}>
|
||||
<FileSignature className="h-4 w-4" aria-hidden />
|
||||
@@ -123,6 +134,17 @@ export function NewDocumentMenu({
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{uploadForSigningOpen && (
|
||||
<UploadForSigningDialog
|
||||
open={uploadForSigningOpen}
|
||||
onOpenChange={setUploadForSigningOpen}
|
||||
interestId={null}
|
||||
documentType="generic"
|
||||
entity={entityType && entityId ? { type: entityType, id: entityId } : undefined}
|
||||
folderId={folderId ?? null}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -139,17 +139,37 @@ const RECIPIENT_COLORS = [
|
||||
'rgb(20 184 166)', // teal-500
|
||||
];
|
||||
|
||||
export interface UploadForSigningEntity {
|
||||
type: 'client' | 'company' | 'yacht';
|
||||
id: string;
|
||||
/** Display label only — used in the dialog header so the rep can
|
||||
* see which entity the doc will be filed under. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface UploadForSigningDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interestId: string;
|
||||
/** Pre-set the document type - the parent (EOI/Contract/Reservation
|
||||
* tab) decides which to upload. EOI here is the upload-draft path;
|
||||
* the template-driven generate flow lives on EoiGenerateDialog. */
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement';
|
||||
/** Required for eoi / contract / reservation_agreement (the pipeline
|
||||
* side effects need it). MUST be null for documentType='generic' —
|
||||
* in that case the upload routes through the generic endpoint and
|
||||
* optionally files the doc against the supplied `entity`. */
|
||||
interestId: string | null;
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
|
||||
/** Optional: client name/email to prefill the first recipient.
|
||||
* When omitted the dialog fetches from the interest. */
|
||||
* When omitted the dialog fetches from the interest (interest-scoped
|
||||
* flows) or leaves the recipient blank (generic flow). */
|
||||
clientPrefill?: { name: string; email: string };
|
||||
/** Generic flow only: routes the resulting file/document row to the
|
||||
* entity's FK column + auto-files it into the entity's system
|
||||
* folder. Ignored when `interestId` is set. */
|
||||
entity?: UploadForSigningEntity;
|
||||
/** Generic flow only: explicit folder placement (e.g. rep is
|
||||
* uploading from within a Documents Hub folder). */
|
||||
folderId?: string | null;
|
||||
/** Generic flow only: caller-supplied success hook. Receives the
|
||||
* new documentId and can invalidate caches / show a toast. */
|
||||
onCreated?: (result: { documentId: string }) => void;
|
||||
}
|
||||
|
||||
export function UploadForSigningDialog({
|
||||
@@ -158,18 +178,25 @@ export function UploadForSigningDialog({
|
||||
interestId,
|
||||
documentType,
|
||||
clientPrefill,
|
||||
entity,
|
||||
folderId,
|
||||
onCreated,
|
||||
}: UploadForSigningDialogProps) {
|
||||
// Re-mount the body on every open so all state resets cleanly. Same
|
||||
// pattern as hard-delete-dialog (set-state-in-effect avoidance).
|
||||
if (!open) return null;
|
||||
const draftKey = interestId ?? entity?.id ?? 'generic';
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[1400px] w-[95vw] max-h-[90vh] overflow-hidden p-0 flex flex-col">
|
||||
<DialogBody
|
||||
key={`${interestId}:${documentType}`}
|
||||
key={`${draftKey}:${documentType}`}
|
||||
interestId={interestId}
|
||||
documentType={documentType}
|
||||
clientPrefill={clientPrefill}
|
||||
entity={entity}
|
||||
folderId={folderId ?? null}
|
||||
onCreated={onCreated}
|
||||
onClose={() => onOpenChange(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
@@ -186,8 +213,8 @@ type Step = 'select-file' | 'configure-recipients' | 'place-fields';
|
||||
* contract upload AND reservation upload in the same browser session
|
||||
* without them clobbering each other.
|
||||
*/
|
||||
function draftStorageKey(interestId: string, documentType: string): string {
|
||||
return `pn-crm.upload-for-signing.draft.v1:${interestId}:${documentType}`;
|
||||
function draftStorageKey(scopeId: string, documentType: string): string {
|
||||
return `pn-crm.upload-for-signing.draft.v1:${scopeId}:${documentType}`;
|
||||
}
|
||||
|
||||
interface PersistedDraft {
|
||||
@@ -200,10 +227,10 @@ interface PersistedDraft {
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
function loadDraft(interestId: string, documentType: string): PersistedDraft | null {
|
||||
function loadDraft(scopeId: string, documentType: string): PersistedDraft | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(draftStorageKey(interestId, documentType));
|
||||
const raw = window.localStorage.getItem(draftStorageKey(scopeId, documentType));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as PersistedDraft;
|
||||
// Defensive shape check - drop drafts that look malformed rather
|
||||
@@ -221,19 +248,19 @@ function loadDraft(interestId: string, documentType: string): PersistedDraft | n
|
||||
}
|
||||
}
|
||||
|
||||
function saveDraft(interestId: string, documentType: string, draft: PersistedDraft): void {
|
||||
function saveDraft(scopeId: string, documentType: string, draft: PersistedDraft): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.setItem(draftStorageKey(interestId, documentType), JSON.stringify(draft));
|
||||
window.localStorage.setItem(draftStorageKey(scopeId, documentType), JSON.stringify(draft));
|
||||
} catch {
|
||||
// localStorage may throw on private mode or quota - swallow.
|
||||
}
|
||||
}
|
||||
|
||||
function clearDraft(interestId: string, documentType: string): void {
|
||||
function clearDraft(scopeId: string, documentType: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.removeItem(draftStorageKey(interestId, documentType));
|
||||
window.localStorage.removeItem(draftStorageKey(scopeId, documentType));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -243,19 +270,30 @@ function DialogBody({
|
||||
interestId,
|
||||
documentType,
|
||||
clientPrefill,
|
||||
entity,
|
||||
folderId,
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
interestId: string;
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement';
|
||||
interestId: string | null;
|
||||
documentType: 'eoi' | 'contract' | 'reservation_agreement' | 'generic';
|
||||
clientPrefill?: { name: string; email: string };
|
||||
entity?: UploadForSigningEntity;
|
||||
folderId?: string | null;
|
||||
onCreated?: (result: { documentId: string }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
// Draft scope: interestId when scoped to a deal, otherwise the
|
||||
// entity id (so the rep can have one in-flight upload per entity),
|
||||
// else 'generic' for the root Documents Hub flow.
|
||||
const draftScopeId = interestId ?? entity?.id ?? 'generic';
|
||||
|
||||
// Hydrate from the persisted draft once on mount. The `key` prop on
|
||||
// the parent re-mounts this body on every open, so this useState
|
||||
// initializer runs once per dialog session.
|
||||
const initialDraft = useMemo(
|
||||
() => loadDraft(interestId, documentType),
|
||||
[interestId, documentType],
|
||||
() => loadDraft(draftScopeId, documentType),
|
||||
[draftScopeId, documentType],
|
||||
);
|
||||
|
||||
const [step, setStep] = useState<Step>(initialDraft?.step ?? 'select-file');
|
||||
@@ -275,7 +313,9 @@ function DialogBody({
|
||||
? 'Sales Contract'
|
||||
: documentType === 'eoi'
|
||||
? 'Expression of Interest'
|
||||
: 'Reservation Agreement';
|
||||
: documentType === 'reservation_agreement'
|
||||
? 'Reservation Agreement'
|
||||
: 'Document';
|
||||
|
||||
// Defaults endpoint - drives the developer/approver prefill.
|
||||
const { data: defaults } = useQuery<{ data: SigningDefaults }>({
|
||||
@@ -285,7 +325,7 @@ function DialogBody({
|
||||
|
||||
// Interest endpoint - used to prefill the client recipient when the
|
||||
// caller didn't supply one. Cached so the same dialog open/reopen
|
||||
// hits the cache.
|
||||
// hits the cache. Skipped entirely on the generic path (no interest).
|
||||
const { data: interestData } = useQuery<{
|
||||
data: { client: { fullName: string; email: string | null } };
|
||||
}>({
|
||||
@@ -294,7 +334,7 @@ function DialogBody({
|
||||
apiFetch<{ data: { client: { fullName: string; email: string | null } } }>(
|
||||
`/api/v1/interests/${interestId}`,
|
||||
),
|
||||
enabled: !clientPrefill,
|
||||
enabled: Boolean(interestId) && !clientPrefill,
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -381,11 +421,11 @@ function DialogBody({
|
||||
fields.length > 0 ||
|
||||
invitationMessage.length > 0;
|
||||
if (!hasProgress) {
|
||||
clearDraft(interestId, documentType);
|
||||
clearDraft(draftScopeId, documentType);
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
saveDraft(interestId, documentType, {
|
||||
saveDraft(draftScopeId, documentType, {
|
||||
step,
|
||||
title,
|
||||
recipients,
|
||||
@@ -399,10 +439,10 @@ function DialogBody({
|
||||
return () => {
|
||||
if (draftDebounceRef.current) clearTimeout(draftDebounceRef.current);
|
||||
};
|
||||
}, [step, title, recipients, fields, invitationMessage, interestId, documentType]);
|
||||
}, [step, title, recipients, fields, invitationMessage, draftScopeId, documentType]);
|
||||
|
||||
function discardDraft() {
|
||||
clearDraft(interestId, documentType);
|
||||
clearDraft(draftScopeId, documentType);
|
||||
setTitle('');
|
||||
setRecipients([]);
|
||||
setFields([]);
|
||||
@@ -479,7 +519,23 @@ function DialogBody({
|
||||
})),
|
||||
),
|
||||
);
|
||||
const res = await fetch(`/api/v1/interests/${interestId}/upload-for-signing`, {
|
||||
// Generic envelopes go to the cross-cutting endpoint; the
|
||||
// entity / folder context piggybacks on the form so the file
|
||||
// row lands under the right system folder. Interest-scoped
|
||||
// flows keep their dedicated route so the pipeline-stage
|
||||
// advance + doc-status flip side effects fire.
|
||||
if (interestId) {
|
||||
if (documentType === 'generic') {
|
||||
throw new Error('Generic documentType requires interestId=null');
|
||||
}
|
||||
} else {
|
||||
if (entity) form.append('entity', JSON.stringify({ type: entity.type, id: entity.id }));
|
||||
if (folderId) form.append('folderId', folderId);
|
||||
}
|
||||
const endpoint = interestId
|
||||
? `/api/v1/interests/${interestId}/upload-for-signing`
|
||||
: `/api/v1/upload-for-signing`;
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
credentials: 'include',
|
||||
@@ -506,11 +562,14 @@ function DialogBody({
|
||||
);
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'documents' });
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'interest' });
|
||||
void res;
|
||||
queryClient.invalidateQueries({ predicate: (q) => q.queryKey[0] === 'files' });
|
||||
if (onCreated && res?.data?.documentId) {
|
||||
onCreated({ documentId: res.data.documentId });
|
||||
}
|
||||
// Clear the draft on successful submission - the in-flight upload
|
||||
// is now an actual document; the localStorage shouldn't keep its
|
||||
// shadow around.
|
||||
clearDraft(interestId, documentType);
|
||||
clearDraft(draftScopeId, documentType);
|
||||
onClose();
|
||||
},
|
||||
onError: (err) => toastError(err, 'Upload failed'),
|
||||
|
||||
@@ -87,7 +87,20 @@ export interface CustomDocumentRecipient {
|
||||
}
|
||||
|
||||
export interface UploadDocumentForSigningArgs {
|
||||
interestId: string;
|
||||
/** Optional interest the doc is filed under. Required for eoi /
|
||||
* contract / reservation_agreement (their pipeline-stage side
|
||||
* effects need it); MUST be null for 'generic' (cross-cutting
|
||||
* envelopes that aren't tied to a sales deal). */
|
||||
interestId: string | null;
|
||||
/** Optional entity context — drives the auto-filed folder + the
|
||||
* file-row FK. Used by the 'generic' path when there's no interest
|
||||
* to derive the client from. Ignored when `interestId` is set
|
||||
* (the service resolves the client off the interest itself). */
|
||||
entity?: { type: 'client' | 'company' | 'yacht'; id: string } | null;
|
||||
/** Optional explicit folder placement. When set, overrides the
|
||||
* entity-derived folder (e.g. rep dropped the upload into a
|
||||
* specific subfolder from the Documents Hub). */
|
||||
folderId?: string | null;
|
||||
portId: string;
|
||||
portSlug: string;
|
||||
documentType: CustomDocumentType;
|
||||
@@ -125,6 +138,8 @@ export async function uploadDocumentForSigning(
|
||||
): Promise<UploadDocumentForSigningResult> {
|
||||
const {
|
||||
interestId,
|
||||
entity,
|
||||
folderId: explicitFolderId,
|
||||
portId,
|
||||
portSlug,
|
||||
documentType,
|
||||
@@ -137,6 +152,21 @@ export async function uploadDocumentForSigning(
|
||||
meta,
|
||||
} = args;
|
||||
|
||||
// Generic envelopes (no pipeline-stage advance / no interest) MUST
|
||||
// come in with interestId=null; non-generic types MUST carry an
|
||||
// interest. Reject the mismatch here so the rest of the function can
|
||||
// assume the right invariant.
|
||||
if (documentType !== 'generic' && !interestId) {
|
||||
throw new ValidationError(
|
||||
`${documentType} document requires an interestId — only 'generic' documents can be uploaded without one`,
|
||||
);
|
||||
}
|
||||
if (documentType === 'generic' && interestId) {
|
||||
throw new ValidationError(
|
||||
'Generic documents cannot carry an interestId — use a type-specific document type instead',
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────
|
||||
if (recipients.length === 0) {
|
||||
throw new ValidationError('At least one recipient is required');
|
||||
@@ -175,10 +205,15 @@ export async function uploadDocumentForSigning(
|
||||
}
|
||||
|
||||
// ─── Tenant guard ────────────────────────────────────────────────
|
||||
const interest = await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
// Non-generic types resolve their interest (and derive the client
|
||||
// from there). Generic types skip the interest lookup; entity FK
|
||||
// routing comes from the caller-supplied `entity` arg.
|
||||
const interest = interestId
|
||||
? await db.query.interests.findFirst({
|
||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||
})
|
||||
: null;
|
||||
if (interestId && !interest) throw new NotFoundError('Interest');
|
||||
|
||||
const port = await db.query.ports.findFirst({ where: eq(ports.id, portId) });
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
@@ -200,10 +235,14 @@ export async function uploadDocumentForSigning(
|
||||
: documentType === 'eoi'
|
||||
? 'eoi-source'
|
||||
: 'signed-source';
|
||||
// Storage path groups by interestId when we have one; for generic
|
||||
// uploads the entity id (or a synthetic 'unfiled' bucket) keeps the
|
||||
// namespace tidy.
|
||||
const storageGroupId = interestId ?? entity?.id ?? 'unfiled';
|
||||
const sourceStoragePath = buildStoragePath(
|
||||
portSlug,
|
||||
storageCategory,
|
||||
interestId,
|
||||
storageGroupId,
|
||||
sourceFileId,
|
||||
'pdf',
|
||||
);
|
||||
@@ -214,11 +253,16 @@ export async function uploadDocumentForSigning(
|
||||
sizeBytes: pdfBuffer.length,
|
||||
});
|
||||
|
||||
// Look up the interest's primary client so the auto-filed folder
|
||||
// ends up under the right entity subfolder. Falls back to root when
|
||||
// the chain has no resolvable owner.
|
||||
let entityFolderId: string | null = null;
|
||||
if (interest.clientId) {
|
||||
// Folder placement priority:
|
||||
// 1. Caller-supplied `folderId` (rep dropped the upload into a
|
||||
// specific Documents Hub folder).
|
||||
// 2. Interest's primary client folder (legacy path for
|
||||
// EOI/contract/reservation tabs).
|
||||
// 3. Caller-supplied entity (generic path: client/company/yacht
|
||||
// doc tab originated the upload).
|
||||
// 4. Root (fallback).
|
||||
let entityFolderId: string | null = explicitFolderId ?? null;
|
||||
if (entityFolderId === null && interest?.clientId) {
|
||||
try {
|
||||
const folder = await ensureEntityFolder(portId, 'client', interest.clientId, 'system');
|
||||
entityFolderId = folder.id;
|
||||
@@ -229,12 +273,38 @@ export async function uploadDocumentForSigning(
|
||||
);
|
||||
}
|
||||
}
|
||||
if (entityFolderId === null && entity) {
|
||||
try {
|
||||
const folder = await ensureEntityFolder(portId, entity.type, entity.id, 'system');
|
||||
entityFolderId = folder.id;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, entity },
|
||||
'ensureEntityFolder failed for generic upload entity - filing at root',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the entity-FK fields on the `files` row from whichever
|
||||
// source we have. Interest-derived takes priority; otherwise the
|
||||
// generic `entity` arg maps to its corresponding column.
|
||||
const fileEntityFKs: {
|
||||
clientId: string | null;
|
||||
companyId: string | null;
|
||||
yachtId: string | null;
|
||||
} = {
|
||||
clientId: interest?.clientId ?? (entity?.type === 'client' ? entity.id : null),
|
||||
companyId: entity?.type === 'company' ? entity.id : null,
|
||||
yachtId: entity?.type === 'yacht' ? entity.id : null,
|
||||
};
|
||||
|
||||
const [sourceFileRecord] = await db
|
||||
.insert(files)
|
||||
.values({
|
||||
portId,
|
||||
clientId: interest.clientId,
|
||||
clientId: fileEntityFKs.clientId,
|
||||
companyId: fileEntityFKs.companyId,
|
||||
yachtId: fileEntityFKs.yachtId,
|
||||
folderId: entityFolderId,
|
||||
filename,
|
||||
originalName: filename,
|
||||
@@ -259,7 +329,9 @@ export async function uploadDocumentForSigning(
|
||||
.values({
|
||||
portId,
|
||||
interestId,
|
||||
clientId: interest.clientId,
|
||||
clientId: fileEntityFKs.clientId,
|
||||
companyId: fileEntityFKs.companyId,
|
||||
yachtId: fileEntityFKs.yachtId,
|
||||
fileId: sourceFileRecord.id,
|
||||
documentType,
|
||||
title,
|
||||
@@ -412,7 +484,7 @@ export async function uploadDocumentForSigning(
|
||||
// per-type doc-status flip - they're cross-cutting envelopes that
|
||||
// happen to be filed against this interest. The eoi / contract /
|
||||
// reservation_agreement branches keep their existing side effects.
|
||||
if (documentType !== 'generic') {
|
||||
if (documentType !== 'generic' && interestId) {
|
||||
const stageByType: Record<
|
||||
Exclude<CustomDocumentType, 'generic'>,
|
||||
'eoi' | 'contract' | 'reservation'
|
||||
|
||||
Reference in New Issue
Block a user