diff --git a/src/app/api/v1/companies/autocomplete/handlers.ts b/src/app/api/v1/companies/autocomplete/handlers.ts index f08b3e70..25dabd24 100644 --- a/src/app/api/v1/companies/autocomplete/handlers.ts +++ b/src/app/api/v1/companies/autocomplete/handlers.ts @@ -6,10 +6,13 @@ import { autocomplete } from '@/lib/services/companies.service'; export const autocompleteHandler: RouteHandler = async (req, ctx) => { try { - const q = req.nextUrl.searchParams.get('q'); - if (!q) { - return NextResponse.json({ data: [] }); - } + // Empty `q` returns the most recent companies (capped to the same + // 10-row limit the search path uses). The companion CompanyPicker + // initially renders with an empty query because the popover opens + // before the rep has typed anything — returning [] there hid every + // company even when the system had rows. Returning a recent list + // gives the rep something to scan or select immediately. + const q = req.nextUrl.searchParams.get('q') ?? ''; const companies = await autocomplete(ctx.portId, q); return NextResponse.json({ data: companies }); } catch (error) { diff --git a/src/components/clients/client-form.tsx b/src/components/clients/client-form.tsx index a4663e24..0cb1976e 100644 --- a/src/components/clients/client-form.tsx +++ b/src/components/clients/client-form.tsx @@ -44,6 +44,11 @@ interface ClientFormProps { * or opening the create-interest dialog pre-filled with that * clientId. Skipped in edit mode. */ onUseExistingClient?: (clientId: string) => void; + /** Optional callback fired with the newly-created client's id after a + * successful create. Lets a parent flow (e.g. the new-interest drawer) + * chain a "use the client I just created" follow-up without making the + * rep re-select. Not called in edit mode. */ + onCreated?: (clientId: string) => void; /** Optional initial values for the create flow - used by the * inquiry-inbox "Convert to client" triage step (P-4.5) so the rep * doesn't retype values they just read in the inbox. The @@ -84,6 +89,7 @@ export function ClientForm({ onOpenChange, client, onUseExistingClient, + onCreated, prefill, }: ClientFormProps) { const queryClient = useQueryClient(); @@ -253,6 +259,7 @@ export function ClientForm({ body: { tagIds: tIds }, }); } + return null; } else { const res = await apiFetch<{ data: { id: string } }>('/api/v1/clients', { method: 'POST', @@ -287,13 +294,19 @@ export function ClientForm({ ); } } + return { id: res.data.id }; } }, - onSuccess: () => { + onSuccess: (result) => { queryClient.invalidateQueries({ queryKey: ['clients'] }); // M-U10: confirm the write landed. Without this the rep closes // the sheet not sure whether the create/edit actually saved. toast.success(isEdit ? 'Client updated' : 'Client created'); + // Parent flow gets a chance to chain off the new id (e.g. the + // new-interest drawer auto-selects the freshly-created client). + if (result && 'id' in result && result.id && onCreated) { + onCreated(result.id); + } onOpenChange(false); }, }); diff --git a/src/components/documents/create-document-wizard.tsx b/src/components/documents/create-document-wizard.tsx index 07c72778..00e0b641 100644 --- a/src/components/documents/create-document-wizard.tsx +++ b/src/components/documents/create-document-wizard.tsx @@ -25,7 +25,7 @@ import { DocumentTemplatePicker } from '@/components/documents/document-template import { FileUploadZone } from '@/components/files/file-upload-zone'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; -import { DOCUMENT_TYPES } from '@/lib/constants'; +import { DOCUMENT_TYPES, DOCUMENT_TYPE_LABELS } from '@/lib/constants'; // Display labels for SIGNER_ROLES - internal values stay lowercase, UI shows // capitalized. Falls back to capitalize-first-letter for any value not in the @@ -311,11 +311,17 @@ export function CreateDocumentWizard({ portSlug }: CreateDocumentWizardProps) { {DOCUMENT_TYPES.map((t) => ( - {t.replace(/_/g, ' ')} + {DOCUMENT_TYPE_LABELS[t]} ))} + {documentType === 'other' ? ( +

+ Use the Title below to describe the document - that's how it'll appear + everywhere it's referenced. +

+ ) : null}
diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index d8ce66e8..34ddf6d5 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -43,6 +43,24 @@ interface HubDoc { signers?: Array<{ id: string; signerEmail: string; signerName: string; status: string }>; } +/** + * Lightweight files-table row used by FlatFolderListing's parallel files + * query. Folder views show both signature documents (the `documents` + * table) AND plain uploaded files (the `files` table) so reps see + * everything that physically lives in the folder in one list. The two + * sources merge into a unified `HubRow` for rendering. + */ +interface HubFile { + id: string; + filename: string; + originalName: string | null; + mimeType: string | null; + sizeBytes: number; + createdAt: string; +} + +type HubRow = { kind: 'doc'; doc: HubDoc } | { kind: 'file'; file: HubFile }; + const TYPE_LABELS: Record = { eoi: 'EOI', contract: 'Contract', @@ -335,6 +353,57 @@ function FlatFolderListing({ filterDefinitions: [], }); + // Plain uploaded files in this folder. Lives in a separate table from + // signature documents, so we query it in parallel and merge into the + // unified row list. Skip the search/typeFilter for files (those filters + // are doc-specific); applying them client-side would create a confusing + // partial view where the search bar hides files unrelated to the query. + // folderId === null → root; UUID → that folder; the endpoint resolves + // `folderId=` as null to keep the URL idempotent across folder switches. + const filesQueryString = useMemo(() => { + const p = new URLSearchParams(); + p.set('folderId', folderId ?? ''); + p.set('limit', '50'); + p.set('sort', 'createdAt'); + p.set('order', 'desc'); + return p.toString(); + }, [folderId]); + const { data: filesResp, isLoading: filesLoading } = useQuery<{ data: HubFile[] }>({ + queryKey: ['files', 'hub', 'folder', filesQueryString], + queryFn: async (): Promise<{ data: HubFile[] }> => { + const res = await fetch(`/api/v1/files?${filesQueryString}`, { credentials: 'include' }); + if (!res.ok) return { data: [] }; + return (await res.json()) as { data: HubFile[] }; + }, + }); + const folderFiles: HubFile[] = useMemo(() => filesResp?.data ?? [], [filesResp]); + + // Merge docs + files, sorted by createdAt desc so the most-recent item + // (regardless of source) is at the top. Filter file rows out when the + // type-chip is active (chips filter docs only — files have no + // documentType column, treating them as "uploads" type means they'd + // disappear under any chip selection, which is the right UX). + const mergedRows: HubRow[] = useMemo(() => { + const docRows = documents.map((doc) => ({ kind: 'doc' as const, doc })); + const fileRows = typeFilter + ? [] + : folderFiles + // Client-side search across filenames so the search box covers + // both sources uniformly. + .filter((f) => + search + ? (f.filename ?? '').toLowerCase().includes(search.toLowerCase()) || + (f.originalName ?? '').toLowerCase().includes(search.toLowerCase()) + : true, + ) + .map((file) => ({ kind: 'file' as const, file })); + return [...docRows, ...fileRows].sort((a, b) => { + const aTime = a.kind === 'doc' ? a.doc.createdAt : a.file.createdAt; + const bTime = b.kind === 'doc' ? b.doc.createdAt : b.file.createdAt; + return bTime.localeCompare(aTime); + }); + }, [documents, folderFiles, typeFilter, search]); + // Realtime invalidation is lifted to DocumentsHub so it survives mode // switches (root / entity-folder / flat-folder). Don't re-subscribe here. @@ -357,19 +426,25 @@ function FlatFolderListing({ className="border-b last:border-b-0 transition-colors hover:bg-gradient-brand-soft/40" >
- + {totalSigners > 0 ? ( + + ) : ( + // Keep the column reserved so the grid layout stays aligned + // across rows; this row has no signers to expand into. + + )} { + return ( +
  • +
    + {/* Empty action column to align with doc-row layout */} + + + {file.originalName ?? file.filename} + + Uploaded file + + Stored + + + {(file.sizeBytes / 1024).toFixed(0)} KB + + + {new Date(file.createdAt).toLocaleDateString(undefined)} + +
    +
  • + ); + }; + return ( - <> +
    ) : null} - {isLoading ? ( + {isLoading || filesLoading ? (
      {[0, 1, 2, 3, 4].map((i) => (
    • ))}
    - ) : documents.length === 0 && childFolders.length === 0 ? ( + ) : mergedRows.length === 0 && childFolders.length === 0 ? ( } title="No documents in this folder" @@ -510,7 +621,11 @@ function FlatFolderListing({ } /> ) : ( -
      {documents.map(renderRow)}
    +
      + {mergedRows.map((row) => + row.kind === 'doc' ? renderRow(row.doc) : renderFileRow(row.file), + )} +
    )} @@ -535,7 +650,7 @@ function FlatFolderListing({ /> - +
    ); } diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index eb6a0307..b9f64816 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -106,7 +106,7 @@ export function FilePreviewDialog({ return ( - + {fileName ?? 'Preview'} diff --git a/src/components/interests/berth-recommender-panel.tsx b/src/components/interests/berth-recommender-panel.tsx index 273b0e60..f270b280 100644 --- a/src/components/interests/berth-recommender-panel.tsx +++ b/src/components/interests/berth-recommender-panel.tsx @@ -179,8 +179,10 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { >
    + {/* Mooring number already carries the area letter prefix + (canonical `^[A-Z]+\d+$`), so the trailing `· A` was pure + visual noise — same call as the BerthPicker grouping. */} {rec.mooringNumber} - {rec.area ? {rec.area} : null} {formatStatus(rec.status)} @@ -219,10 +221,51 @@ function RecommendationCard({ rec, portSlug, onAdd }: RecommendationCardProps) { {showHeat ? ( - - - Heat {Math.round(rec.heat!.total)} - + + + + + +

    + Heat score · {Math.round(rec.heat!.total)} / 100 +

    +

    + How warm this berth is for a re-pitch. Calculated from past interest history: + how recent the last fall-through was, how far along it got, how many distinct + interests touched it, and how many of those reached EOI. Higher = better target. +

    +
      +
    • + Recency:{' '} + {Math.round(rec.heat!.recency)} +
    • +
    • + Furthest stage:{' '} + {Math.round(rec.heat!.furthestStage)} +
    • +
    • + Interest count:{' '} + {Math.round(rec.heat!.interestCount)} +
    • +
    • + EOI count:{' '} + {Math.round(rec.heat!.eoiCount)} +
    • +
    +

    + Admins tune the weights in{' '} + Admin → Recommender. +

    +
    +
    ) : null}
    diff --git a/src/components/interests/interest-form.tsx b/src/components/interests/interest-form.tsx index 5231001a..b194bd98 100644 --- a/src/components/interests/interest-form.tsx +++ b/src/components/interests/interest-form.tsx @@ -43,6 +43,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Separator } from '@/components/ui/separator'; import { TagPicker } from '@/components/shared/tag-picker'; import { ReminderDaysInput } from '@/components/shared/reminder-days-input'; +import { ClientForm } from '@/components/clients/client-form'; import { YachtForm } from '@/components/yachts/yacht-form'; import { YachtPicker } from '@/components/yachts/yacht-picker'; import { apiFetch } from '@/lib/api/client'; @@ -127,6 +128,7 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: const selectedBerthId = watch('berthId'); const selectedYachtId = watch('yachtId'); const [createYachtOpen, setCreateYachtOpen] = useState(false); + const [createClientOpen, setCreateClientOpen] = useState(false); const [discardConfirmOpen, setDiscardConfirmOpen] = useState(false); // Auto-fill pipelineStage + leadCategory based on whether a berth was @@ -258,6 +260,10 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }: clientId: defaultClientId ?? '', yachtId: undefined, pipelineStage: 'enquiry', + // Mirror the defaultValues block — manual-create flow always + // defaults source to 'manual'. The reset path was dropping it, + // leaving a freshly-opened drawer with a blank source selector. + source: 'manual', reminderEnabled: false, tagIds: [], }); @@ -369,7 +375,21 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
    - +
    + + {!isEdit && ( + + )} +