From 05950ae0b62f5406f01ad945f503b49e23767191 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 3 Jun 2026 22:34:47 +0200 Subject: [PATCH] feat(uat): file preview/download fix, clients-by-country page, residential column picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch #4 UAT items. 1. Documents — clicking any file dumped raw presigned-URL JSON. Was systemic: 6 surfaces linked a browser directly at the JSON-returning /files/[id]/{download,preview} routes. Those routes now 302-redirect when called with ?redirect=1 (default stays JSON for the dialog + interest-eoi-tab programmatic consumers); the six sites use it. The documents-hub file row now opens the inline FilePreviewDialog + has a per-row Download button, and the preview dialog header gained a persistent Download button for all file types. 2. Clients-by-country — the widget's "+N more" dead text is now a "Show all" link to a new /clients/by-country page rendering the full ranked country breakdown (each row drills into the filtered list). 3. Residential clients list — moved off its bespoke table onto the shared DataTable + ColumnPicker (same UX as clients/interests). Adds a "Date added" column, default-hides the empty "Residence" column, preserves the mobile card view, persists per-user column choices. tsc clean, eslint clean, 1584/1584 vitest. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[portSlug]/clients/by-country/page.tsx | 5 + src/app/api/v1/files/[id]/download/route.ts | 7 + src/app/api/v1/files/[id]/preview/route.ts | 6 + .../clients/clients-by-country-page.tsx | 119 +++++++++ .../dashboard/clients-by-country-widget.tsx | 12 +- src/components/documents/document-detail.tsx | 2 +- src/components/documents/documents-hub.tsx | 85 +++++-- src/components/expenses/expense-detail.tsx | 2 +- src/components/files/file-preview-dialog.tsx | 12 + src/components/invoices/invoice-card.tsx | 5 +- src/components/invoices/invoice-columns.tsx | 5 +- .../sub-pages/report-runs-page-client.tsx | 4 +- .../residential-client-columns.tsx | 180 ++++++++++++++ .../residential/residential-clients-list.tsx | 230 +++++++----------- src/components/tenancies/tenancy-detail.tsx | 2 +- 15 files changed, 506 insertions(+), 170 deletions(-) create mode 100644 src/app/(dashboard)/[portSlug]/clients/by-country/page.tsx create mode 100644 src/components/clients/clients-by-country-page.tsx create mode 100644 src/components/residential/residential-client-columns.tsx diff --git a/src/app/(dashboard)/[portSlug]/clients/by-country/page.tsx b/src/app/(dashboard)/[portSlug]/clients/by-country/page.tsx new file mode 100644 index 00000000..aac493db --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/clients/by-country/page.tsx @@ -0,0 +1,5 @@ +import { ClientsByCountryPage } from '@/components/clients/clients-by-country-page'; + +export default function ClientsByCountryRoute() { + return ; +} diff --git a/src/app/api/v1/files/[id]/download/route.ts b/src/app/api/v1/files/[id]/download/route.ts index 58e43b9b..7166c4dc 100644 --- a/src/app/api/v1/files/[id]/download/route.ts +++ b/src/app/api/v1/files/[id]/download/route.ts @@ -8,6 +8,13 @@ export const GET = withAuth( withPermission('files', 'view', async (req, ctx, params) => { try { const result = await getDownloadUrl(params.id!, ctx.portId); + // `?redirect=1` → 302 straight to the presigned (attachment) URL so a + // plain / downloads the file. Without it we return the + // JSON envelope for programmatic consumers (e.g. fetch + anchor click). + // Linking the browser at the JSON form used to dump raw `{data:{url}}`. + if (new URL(req.url).searchParams.has('redirect')) { + return NextResponse.redirect(result.url, 302); + } return NextResponse.json({ data: result }); } catch (error) { return errorResponse(error); diff --git a/src/app/api/v1/files/[id]/preview/route.ts b/src/app/api/v1/files/[id]/preview/route.ts index 6ddf97ab..7d45ed29 100644 --- a/src/app/api/v1/files/[id]/preview/route.ts +++ b/src/app/api/v1/files/[id]/preview/route.ts @@ -8,6 +8,12 @@ export const GET = withAuth( withPermission('files', 'view', async (req, ctx, params) => { try { const result = await getPreviewUrl(params.id!, ctx.portId); + // `?redirect=1` → 302 to the presigned (inline) URL so a plain + // / opens the file in the browser. Default returns the + // JSON envelope for programmatic consumers (e.g. FilePreviewDialog). + if (new URL(req.url).searchParams.has('redirect')) { + return NextResponse.redirect(result.url, 302); + } return NextResponse.json({ data: result }); } catch (error) { return errorResponse(error); diff --git a/src/components/clients/clients-by-country-page.tsx b/src/components/clients/clients-by-country-page.tsx new file mode 100644 index 00000000..876c9354 --- /dev/null +++ b/src/components/clients/clients-by-country-page.tsx @@ -0,0 +1,119 @@ +'use client'; + +import Link from 'next/link'; +import type { Route } from 'next'; +import { useParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { Globe } from 'lucide-react'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PageHeader } from '@/components/shared/page-header'; +import { apiFetch } from '@/lib/api/client'; +import { CountryFlag } from '@/components/shared/country-flag'; +import { getCountryName } from '@/lib/i18n/countries'; +import { cn } from '@/lib/utils'; + +interface ClientsByCountryRow { + country: string; + count: number; +} + +interface ClientsByCountryResponse { + data: ClientsByCountryRow[]; + total: number; +} + +/** + * Full per-country breakdown of the active (non-archived) client book — the + * "Show all" destination for the dashboard `ClientsByCountryWidget`, which + * only shows the top N. Same endpoint (it already returns every row); this + * page just renders the complete ranked list. Each row deep-links into the + * clients list filtered by that nationality. + */ +export function ClientsByCountryPage() { + const params = useParams<{ portSlug: string }>(); + const portSlug = params?.portSlug ?? ''; + + const { data, isLoading } = useQuery({ + queryKey: ['dashboard', 'clients-by-country', 'all'], + queryFn: () => apiFetch('/api/v1/dashboard/clients-by-country'), + staleTime: 60_000, + }); + + const rows = data?.data ?? []; + const total = data?.total ?? rows.reduce((s, r) => s + r.count, 0); + const maxCount = rows.reduce((m, r) => Math.max(m, r.count), 0) || 1; + + return ( +
+ 0 + ? `${total} client${total === 1 ? '' : 's'} across ${rows.length} ${ + rows.length === 1 ? 'country' : 'countries' + }.` + : undefined + } + /> + + + + {isLoading ? ( +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+ ) : rows.length === 0 ? ( +
+ +

No clients with a country recorded yet.

+
+ ) : ( +
    + {rows.map((row, i) => { + const pct = (row.count / maxCount) * 100; + const name = getCountryName(row.country) || row.country; + return ( +
  1. + +
    + + {i + 1} + + + {name} +
    +
    +
    +
    +
    + + {row.count} + +
    + +
  2. + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/components/dashboard/clients-by-country-widget.tsx b/src/components/dashboard/clients-by-country-widget.tsx index 1fa76d3b..0ee7fc81 100644 --- a/src/components/dashboard/clients-by-country-widget.tsx +++ b/src/components/dashboard/clients-by-country-widget.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import type { Route } from 'next'; import { useParams } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; -import { Globe } from 'lucide-react'; +import { ArrowRight, Globe } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Skeleton } from '@/components/ui/skeleton'; @@ -126,8 +126,14 @@ export function ClientsByCountryWidget({ limit = 8 }: { limit?: number } = {}) { ); })} {hiddenCount > 0 ? ( -
  • - + {hiddenCount} more {hiddenCount === 1 ? 'country' : 'countries'} not shown. +
  • + + Show all {rows.length} countries + +
  • ) : null} diff --git a/src/components/documents/document-detail.tsx b/src/components/documents/document-detail.tsx index d0bbf2cb..ab3cb879 100644 --- a/src/components/documents/document-detail.tsx +++ b/src/components/documents/document-detail.tsx @@ -299,7 +299,7 @@ export function DocumentDetail({ documentId, portSlug }: DocumentDetailProps) { {isComplete && doc.signedFileId ? ( <> diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 34ddf6d5..64bd8ded 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -4,7 +4,16 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { ChevronDown, ChevronRight, FileText, Folder, Lock, Plus, Upload } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + Download, + FileText, + Folder, + Lock, + Plus, + Upload, +} from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -16,9 +25,11 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; +import { apiFetch } from '@/lib/api/client'; import { StatusPill, type StatusPillStatus } from '@/components/ui/status-pill'; import { EmptyState } from '@/components/ui/empty-state'; import { FileUploadZone } from '@/components/files/file-upload-zone'; +import { FilePreviewDialog } from '@/components/files/file-preview-dialog'; import { PageHeader } from '@/components/shared/page-header'; import { PermissionGate } from '@/components/shared/permission-gate'; import { usePaginatedQuery } from '@/hooks/use-paginated-query'; @@ -336,6 +347,30 @@ function FlatFolderListing({ const [typeFilter, setTypeFilter] = useState(undefined); const [expandedDocId, setExpandedDocId] = useState(null); const [uploadOpen, setUploadOpen] = useState(false); + // File selected for inline preview. Clicking a file row opens the shared + // FilePreviewDialog rather than navigating the browser at the JSON-returning + // `/files/[id]/download` endpoint (which used to dump raw `{data:{url}}`). + const [previewFile, setPreviewFile] = useState(null); + + // Force-download a stored file: the `/download` route returns a presigned + // URL (content-disposition=attachment) as a JSON envelope, so we fetch it + // then click a hidden anchor. Avoids navigating the tab to the raw JSON. + const downloadFile = useCallback(async (file: HubFile) => { + try { + const { data } = await apiFetch<{ data: { url: string; filename: string } }>( + `/api/v1/files/${file.id}/download`, + ); + const a = document.createElement('a'); + a.href = data.url; + a.rel = 'noopener'; + a.download = file.originalName ?? file.filename; + document.body.appendChild(a); + a.click(); + a.remove(); + } catch { + // apiFetch surfaces its own toast on failure; nothing else to do here. + } + }, []); const queryClient = useQueryClient(); const queryParams = useMemo(() => { @@ -489,9 +524,10 @@ function FlatFolderListing({ }; // Uploaded-file row — simpler than a signature doc since there's no - // signer/status concept. Links to the underlying file via download URL - // and surfaces an "Uploaded" type pill so the rep distinguishes it - // from signature workflows at a glance. + // signer/status concept. Clicking the name opens an inline preview + // (FilePreviewDialog); a dedicated download button saves the file. An + // "Uploaded" type pill distinguishes it from signature workflows. + const fileLabel = (file: HubFile) => file.originalName ?? file.filename; const renderFileRow = (file: HubFile) => { return (
  • {/* Empty action column to align with doc-row layout */} - setPreviewFile(file)} + className="min-w-0 truncate text-left font-medium text-foreground hover:text-brand" + title={`Preview ${fileLabel(file)}`} > - {file.originalName ?? file.filename} - + {fileLabel(file)} + Uploaded file Stored @@ -516,9 +552,21 @@ function FlatFolderListing({ {(file.sizeBytes / 1024).toFixed(0)} KB - - {new Date(file.createdAt).toLocaleDateString(undefined)} - +
    + + {new Date(file.createdAt).toLocaleDateString(undefined)} + + +
  • ); @@ -526,6 +574,15 @@ function FlatFolderListing({ return (
    + { + if (!o) setPreviewFile(null); + }} + fileId={previewFile?.id} + fileName={previewFile ? fileLabel(previewFile) : undefined} + mimeType={previewFile?.mimeType ?? undefined} + />
    {mime || (isError ? 'Receipt' : 'File')} Download diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index cb784169..007b2ba1 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -121,12 +121,24 @@ export function FilePreviewDialog({ {fileName ?? 'Preview'} + {fileId && ( + + + + )} {previewUrl && ( diff --git a/src/components/invoices/invoice-card.tsx b/src/components/invoices/invoice-card.tsx index 998f851a..6d74a96d 100644 --- a/src/components/invoices/invoice-card.tsx +++ b/src/components/invoices/invoice-card.tsx @@ -105,7 +105,10 @@ export function InvoiceCard({ {invoice.pdfFileId ? ( - + View PDF diff --git a/src/components/invoices/invoice-columns.tsx b/src/components/invoices/invoice-columns.tsx index a90b5028..c70f6b38 100644 --- a/src/components/invoices/invoice-columns.tsx +++ b/src/components/invoices/invoice-columns.tsx @@ -144,7 +144,10 @@ export function getInvoiceColumns({ {invoice.pdfFileId && ( - + View PDF diff --git a/src/components/reports/sub-pages/report-runs-page-client.tsx b/src/components/reports/sub-pages/report-runs-page-client.tsx index 01811e5a..0007116f 100644 --- a/src/components/reports/sub-pages/report-runs-page-client.tsx +++ b/src/components/reports/sub-pages/report-runs-page-client.tsx @@ -128,7 +128,9 @@ export function ReportRunsPageClient({ portSlug }: { portSlug: string }) {
    {r.status === 'complete' && r.storageKey ? ( diff --git a/src/components/residential/residential-client-columns.tsx b/src/components/residential/residential-client-columns.tsx new file mode 100644 index 00000000..5e4f08e8 --- /dev/null +++ b/src/components/residential/residential-client-columns.tsx @@ -0,0 +1,180 @@ +'use client'; + +import Link from 'next/link'; +import type { Route } from 'next'; +import { format } from 'date-fns'; +import { Mail, Phone } from 'lucide-react'; +import { WhatsAppIcon } from '@/components/icons/whatsapp'; +import type { ColumnDef } from '@tanstack/react-table'; + +import { Badge } from '@/components/ui/badge'; +import type { ColumnPickerOption } from '@/components/shared/column-picker'; + +export interface ResidentialClientRow { + id: string; + fullName: string; + email: string | null; + phone: string | null; + placeOfResidence: string | null; + status: string; + source: string | null; + createdAt: string; + updatedAt: string; +} + +const STATUS_LABELS: Record = { + prospect: 'Prospect', + active: 'Active', + inactive: 'Inactive', +}; + +/** + * Column manifest for the residential clients list ``. + * Mirrors the marina-side clients/interests pattern so the residential + * team gets the same show/hide affordance. + */ +export const RESIDENTIAL_CLIENT_COLUMN_OPTIONS: ColumnPickerOption[] = [ + { id: 'fullName', label: 'Name', alwaysVisible: true }, + { id: 'email', label: 'Email' }, + { id: 'phone', label: 'Phone' }, + { id: 'residence', label: 'Residence' }, + { id: 'status', label: 'Status' }, + { id: 'source', label: 'Source' }, + { id: 'createdAt', label: 'Date added' }, +]; + +/** + * "Residence" is empty for nearly every residential client (we don't + * capture it at intake — it rendered as a column of "-"), so it's hidden + * by default and "Date added" takes its place. Users can re-enable + * Residence via the picker; their choice then persists. + */ +export const RESIDENTIAL_CLIENT_DEFAULT_HIDDEN: string[] = ['residence']; + +export function getResidentialClientColumns({ + portSlug, +}: { + portSlug: string; +}): ColumnDef[] { + return [ + { + id: 'fullName', + accessorKey: 'fullName', + header: 'Name', + cell: ({ row }) => ( + e.stopPropagation()} + > + {row.original.fullName} + + ), + }, + { + id: 'email', + header: 'Email', + enableSorting: false, + cell: ({ row }) => { + const value = row.original.email; + if (!value) return -; + return ( + e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary hover:underline" + title={`Email ${value}`} + > + + {value} + + ); + }, + }, + { + id: 'phone', + header: 'Phone', + enableSorting: false, + cell: ({ row }) => { + const value = row.original.phone; + if (!value) return -; + const waDigits = value.replace(/[^\d]/g, ''); + return ( + + e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-foreground hover:text-primary hover:underline" + title={`Call ${value}`} + > + + {value} + + e.stopPropagation()} + className="text-emerald-600 hover:text-emerald-700" + title={`WhatsApp ${value}`} + aria-label={`WhatsApp ${value}`} + > + + + + ); + }, + }, + { + id: 'residence', + accessorKey: 'placeOfResidence', + header: 'Residence', + enableSorting: false, + cell: ({ getValue }) => { + const v = getValue() as string | null; + return v ? ( + {v} + ) : ( + - + ); + }, + }, + { + id: 'status', + accessorKey: 'status', + header: 'Status', + cell: ({ getValue }) => { + const s = getValue() as string; + return ( + + {STATUS_LABELS[s] ?? s} + + ); + }, + }, + { + id: 'source', + accessorKey: 'source', + header: 'Source', + cell: ({ getValue }) => { + const source = getValue() as string | null; + if (!source) return -; + return ( + + {source} + + ); + }, + }, + { + id: 'createdAt', + accessorKey: 'createdAt', + header: 'Date added', + cell: ({ getValue }) => ( + + {format(new Date(getValue() as string), 'MMM d, yyyy')} + + ), + }, + ]; +} diff --git a/src/components/residential/residential-clients-list.tsx b/src/components/residential/residential-clients-list.tsx index d9a54c7b..f2866439 100644 --- a/src/components/residential/residential-clients-list.tsx +++ b/src/components/residential/residential-clients-list.tsx @@ -2,10 +2,10 @@ import { useState } from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import type { Route } from 'next'; +import { useParams, useRouter } from 'next/navigation'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Plus } from 'lucide-react'; -import { WhatsAppIcon } from '@/components/icons/whatsapp'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -22,17 +22,15 @@ import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; import type { CountryCode } from '@/lib/i18n/countries'; - -interface ResidentialClientRow { - id: string; - fullName: string; - email: string | null; - phone: string | null; - placeOfResidence: string | null; - status: string; - source: string | null; - updatedAt: string; -} +import { DataTable } from '@/components/shared/data-table'; +import { ColumnPicker } from '@/components/shared/column-picker'; +import { useTablePreferences } from '@/hooks/use-table-preferences'; +import { + getResidentialClientColumns, + RESIDENTIAL_CLIENT_COLUMN_OPTIONS, + RESIDENTIAL_CLIENT_DEFAULT_HIDDEN, + type ResidentialClientRow, +} from '@/components/residential/residential-client-columns'; interface ListResponse { data: ResidentialClientRow[]; @@ -48,6 +46,7 @@ const STATUS_LABELS: Record = { export function ResidentialClientsList() { const params = useParams<{ portSlug: string }>(); const portSlug = params?.portSlug ?? ''; + const router = useRouter(); const [createOpen, setCreateOpen] = useState(false); const [search, setSearch] = useState(''); @@ -66,6 +65,17 @@ export function ResidentialClientsList() { 'residential_client:restored': [['residential-clients']], }); + const columns = getResidentialClientColumns({ portSlug }); + // Per-user column visibility, persisted via /api/v1/me — same hook + UX as + // the marina clients/interests lists. "Residence" is hidden by default + // (it's empty for nearly every residential client); "Date added" is shown. + const { hidden, setHidden } = useTablePreferences( + 'residential-clients', + RESIDENTIAL_CLIENT_DEFAULT_HIDDEN, + ); + const columnVisibility = Object.fromEntries(hidden.map((id) => [id, false])); + const rows = data?.data ?? []; + return (
    -
    +
    setSearch(e.target.value)} className="max-w-sm" /> +
    - {/* Desktop: table layout. Hidden below lg because the 6 columns clip - off the viewport at phone widths. */} -
    - - - - - - - - - - - - - {isLoading && ( - - - - )} - {!isLoading && data?.data.length === 0 && ( - - - - )} - {data?.data.map((c) => ( - - - - - - - - - ))} - -
    NameEmailPhoneResidenceStatusSource
    - Loading… -
    - No residential clients yet. -
    - - {c.fullName} - - - {c.email ? ( - e.stopPropagation()} - > - {c.email} - - ) : ( - '-' - )} - - {c.phone ? ( - - e.stopPropagation()} - > - {c.phone} - - e.stopPropagation()} - > - - - - ) : ( - '-' - )} - {c.placeOfResidence ?? '-'}{STATUS_LABELS[c.status] ?? c.status}{c.source ?? '-'}
    -
    - - {/* Mobile: card list. Each card mirrors the table row data with - name + status pill on top, then meta line(s) below. */} -
    - {isLoading && ( -
    - Loading… -
    - )} - {!isLoading && data?.data.length === 0 && ( -
    + r.id} + onRowClick={(r) => router.push(`/${portSlug}/residential/clients/${r.id}` as Route)} + emptyState={ +
    No residential clients yet.
    - )} - {data?.data.map((c) => ( - -
    -

    {c.fullName}

    - - {STATUS_LABELS[c.status] ?? c.status} - -
    -
    - {c.email ? {c.email} : null} - {c.phone ? {c.phone} : null} - {c.placeOfResidence ? {c.placeOfResidence} : null} - {c.source ? · {c.source} : null} -
    - - ))} -
    + } + cardRender={(row) => } + />
    ); } +/** + * Mobile card for a residential client — DataTable swaps to this below the + * md breakpoint. Self-navigating `` (DataTable's onRowClick only wires + * the desktop table rows). Mirrors the marina-side card density. + */ +function ResidentialClientCard({ + portSlug, + client, +}: { + portSlug: string; + client: ResidentialClientRow; +}) { + return ( + +
    +

    {client.fullName}

    + + {STATUS_LABELS[client.status] ?? client.status} + +
    +
    + {client.email ? {client.email} : null} + {client.phone ? {client.phone} : null} + {client.placeOfResidence ? {client.placeOfResidence} : null} + {client.source ? · {client.source} : null} +
    + + ); +} + function NewResidentialClientSheet({ open, onOpenChange, diff --git a/src/components/tenancies/tenancy-detail.tsx b/src/components/tenancies/tenancy-detail.tsx index 3cbf7b50..b5d44539 100644 --- a/src/components/tenancies/tenancy-detail.tsx +++ b/src/components/tenancies/tenancy-detail.tsx @@ -212,7 +212,7 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
    {completedAgreement.signedFileId ? (