diff --git a/src/app/(dashboard)/[portSlug]/clients/[clientId]/loading.tsx b/src/app/(dashboard)/[portSlug]/clients/[clientId]/loading.tsx new file mode 100644 index 0000000..326d916 --- /dev/null +++ b/src/app/(dashboard)/[portSlug]/clients/[clientId]/loading.tsx @@ -0,0 +1,41 @@ +import { Skeleton } from '@/components/ui/skeleton'; +import { CardSkeleton } from '@/components/shared/loading-skeleton'; + +/** + * Route-level loading UI for the client detail page. Renders while the + * server component resolves the session and the client component bootstraps + * its initial query — replaces the previous empty-header flash on direct + * URL visits. + */ +export default function Loading() { + return ( +
+ {/* Header strip — title, badges, action buttons */} +
+
+ + +
+
+ + + + +
+
+ + {/* Tab strip */} +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ + {/* Two-column overview */} +
+ + +
+
+ ); +} diff --git a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx index 01b4b65..a31ade2 100644 --- a/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx +++ b/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx @@ -97,6 +97,28 @@ export default function NewInvoicePage() { const watchedValues = watch(); const isDepositInvoice = watchedValues.kind === 'deposit'; + // Resolve the selected billing entity to a human name so the review step + // shows "Acme Yacht Charters" instead of "company 4f2a1b…". + const billingEntityRef = watchedValues.billingEntity ?? null; + const { data: billingEntityName } = useQuery<{ name: string }>({ + queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id], + queryFn: async () => { + if (!billingEntityRef) return { name: '' }; + const path = + billingEntityRef.type === 'company' + ? `/api/v1/companies/${billingEntityRef.id}` + : `/api/v1/clients/${billingEntityRef.id}`; + const res = await apiFetch<{ + data: { fullName?: string; name?: string }; + }>(path); + return { + name: res?.data?.fullName ?? res?.data?.name ?? '', + }; + }, + enabled: !!billingEntityRef?.id, + staleTime: 60_000, + }); + // Pre-fill the billing entity from the linked interest's client on launch. useEffect(() => { if (prefilledInterest?.data && !watchedValues.billingEntity) { @@ -356,9 +378,13 @@ export default function NewInvoicePage() {

{watchedValues.billingEntity ? ( <> - {watchedValues.billingEntity.type}{' '} - - {watchedValues.billingEntity.id.slice(0, 12)} + {billingEntityName?.name ? ( + {billingEntityName.name} + ) : ( + Loading… + )}{' '} + + ({watchedValues.billingEntity.type}) ) : ( diff --git a/src/app/api/v1/interests/[id]/timeline/route.ts b/src/app/api/v1/interests/[id]/timeline/route.ts index cbb8cb6..9042bb0 100644 --- a/src/app/api/v1/interests/[id]/timeline/route.ts +++ b/src/app/api/v1/interests/[id]/timeline/route.ts @@ -7,6 +7,7 @@ import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { auditLogs } from '@/lib/db/schema/system'; import { documents, documentEvents } from '@/lib/db/schema/documents'; +import { user } from '@/lib/db/schema/users'; import { stageLabel } from '@/lib/constants'; const OUTCOME_LABELS: Record = { @@ -33,6 +34,10 @@ interface TimelineEvent { action: string; description: string; userId: string | null; + /** Resolved display name for `userId`. `'system'` for auto-events; null when + * the user has been deleted or the event has no actor. Falls back to + * email-localpart if the user has no display name. */ + userName: string | null; createdAt: Date; metadata: Record; } @@ -81,6 +86,27 @@ export const GET = withAuth( const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title])); + // Resolve display names for any `userId` that is a real user row (the + // sentinel value 'system' is used for auto-events and isn't joined). + const realUserIds = Array.from( + new Set(auditRows.map((r) => r.userId).filter((u): u is string => !!u && u !== 'system')), + ); + const userRows = + realUserIds.length > 0 + ? await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(inArray(user.id, realUserIds)) + : []; + const userNameById = new Map( + userRows.map((u) => [u.id, u.name?.trim() || u.email.split('@')[0] || 'User']), + ); + const resolveUserName = (userId: string | null): string | null => { + if (!userId) return null; + if (userId === 'system') return 'system'; + return userNameById.get(userId) ?? null; + }; + // Union and sort const auditEvents: TimelineEvent[] = auditRows.map((row) => ({ id: row.id, @@ -93,6 +119,7 @@ export const GET = withAuth( row.userId, ), userId: row.userId, + userName: resolveUserName(row.userId), createdAt: row.createdAt, metadata: (row.metadata as Record) ?? {}, })); @@ -106,6 +133,7 @@ export const GET = withAuth( action: row.eventType, description: `Document "${title}" ${action}`, userId: null, + userName: null, createdAt: row.createdAt, metadata: (row.eventData as Record) ?? {}, }; @@ -128,6 +156,7 @@ export const GET = withAuth( description: interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`, userId: null, + userName: null, createdAt: created, metadata: { synthetic: true }, }); diff --git a/src/components/documents/documents-hub.tsx b/src/components/documents/documents-hub.tsx index 538fa25..9870f4f 100644 --- a/src/components/documents/documents-hub.tsx +++ b/src/components/documents/documents-hub.tsx @@ -74,6 +74,15 @@ const STATUS_PILL_MAP: Record = { rejected: 'rejected', }; +const SIGNER_STATUS_LABELS: Record = { + pending: 'Pending', + sent: 'Sent', + signed: 'Signed', + declined: 'Declined', + expired: 'Expired', + cancelled: 'Cancelled', +}; + interface DocumentsHubProps { portSlug: string; initialTab?: DocumentsHubTab; @@ -187,7 +196,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps {signer.signerEmail} - {signer.status} + {SIGNER_STATUS_LABELS[signer.status] ?? signer.status} ))} diff --git a/src/components/interests/interest-detail-header.tsx b/src/components/interests/interest-detail-header.tsx index 5cb6e3d..8c5f0a1 100644 --- a/src/components/interests/interest-detail-header.tsx +++ b/src/components/interests/interest-detail-header.tsx @@ -36,6 +36,20 @@ const OUTCOME_BADGE: Record = { cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' }, }; +// Catch-all so an unknown outcome (e.g. a future `lost_no_berth` enum) still +// renders as a closed-state badge instead of falling back to the open-state +// stage picker. Lost-* gets a rose tint; everything else gets neutral slate. +function resolveOutcomeBadge(outcome: string | null | undefined) { + if (!outcome) return null; + const known = OUTCOME_BADGE[outcome]; + if (known) return known; + const isLoss = outcome.startsWith('lost'); + return { + label: outcome.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()), + className: isLoss ? 'bg-rose-100 text-rose-700' : 'bg-slate-200 text-slate-700', + }; +} + const CATEGORY_LABELS: Record = { general_interest: 'General', specific_qualified: 'Specific Qualified', @@ -90,7 +104,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade const [outcomeDialog, setOutcomeDialog] = useState(null); const isArchived = !!interest.archivedAt; - const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null; + const outcomeBadge = resolveOutcomeBadge(interest.outcome); const isClosed = !!interest.outcome; // Contact deep-links — resolved from the linked client's primary channels. diff --git a/src/components/interests/interest-tabs.tsx b/src/components/interests/interest-tabs.tsx index 29c1f00..699e024 100644 --- a/src/components/interests/interest-tabs.tsx +++ b/src/components/interests/interest-tabs.tsx @@ -26,6 +26,12 @@ const LEAD_CATEGORY_OPTIONS = LEAD_CATEGORIES.map((c) => ({ label: c.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()), })); +// Convert raw enum values like `waiting_for_signatures` → `Waiting For Signatures`. +function humanizeStatus(value: string | null): string | null { + if (!value) return null; + return value.replace(/_/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()); +} + interface InterestTabsOptions { interestId: string; currentUserId?: string; @@ -203,7 +209,7 @@ function MilestoneSection({ {status ? ( - {status.replace(/_/g, ' ')} + {humanizeStatus(status)} ) : null} diff --git a/src/components/interests/interest-timeline.tsx b/src/components/interests/interest-timeline.tsx index 52ac13e..dbff32a 100644 --- a/src/components/interests/interest-timeline.tsx +++ b/src/components/interests/interest-timeline.tsx @@ -23,6 +23,8 @@ interface TimelineEvent { action: string; description: string; userId: string | null; + /** Resolved display name (server-side join). Falls back to userId when null. */ + userName?: string | null; createdAt: string; metadata: Record; } @@ -56,10 +58,14 @@ function eventIcon(event: TimelineEvent) { return ; } -function actorLabel(userId: string | null): string | null { - if (!userId) return null; - if (userId === 'system') return 'system'; - return userId; +function actorLabel(event: TimelineEvent): string | null { + if (event.userName) return event.userName; + if (!event.userId) return null; + if (event.userId === 'system') return 'system'; + // Last-resort fallback when the user row was deleted: show a short token + // instead of a 36-char UUID. The server-side join is authoritative; this + // path should be rare in practice. + return 'a teammate'; } export function InterestTimeline({ interestId }: InterestTimelineProps) { @@ -100,7 +106,7 @@ export function InterestTimeline({ interestId }: InterestTimelineProps) {

{events.map((event) => { - const actor = actorLabel(event.userId); + const actor = actorLabel(event); const isAuto = event.userId === 'system'; return (
diff --git a/src/components/invoices/invoice-detail.tsx b/src/components/invoices/invoice-detail.tsx index ed6b11c..37f8913 100644 --- a/src/components/invoices/invoice-detail.tsx +++ b/src/components/invoices/invoice-detail.tsx @@ -6,12 +6,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Loader2, Send, CreditCard } from 'lucide-react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; +import { format } from 'date-fns'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { InvoicePdfPreview } from './invoice-pdf-preview'; import { apiFetch } from '@/lib/api/client'; @@ -26,6 +34,40 @@ const STATUS_COLORS: Record = { cancelled: 'bg-gray-100 text-gray-500 border-gray-200', }; +// Display labels for snake_case enum values stored in the DB. +const PAYMENT_METHOD_LABELS: Record = { + bank_transfer: 'Bank transfer', + credit_card: 'Credit card', + cash: 'Cash', + cheque: 'Cheque', + check: 'Check', + wire: 'Wire', + other: 'Other', +}; + +const PAYMENT_METHOD_OPTIONS: Array<{ value: string; label: string }> = [ + { value: 'bank_transfer', label: 'Bank transfer' }, + { value: 'credit_card', label: 'Credit card' }, + { value: 'cash', label: 'Cash' }, + { value: 'cheque', label: 'Cheque' }, + { value: 'wire', label: 'Wire' }, + { value: 'other', label: 'Other' }, +]; + +function formatPaymentMethod(method: string | null | undefined): string { + if (!method) return '—'; + return PAYMENT_METHOD_LABELS[method] ?? method.replace(/_/g, ' '); +} + +function formatDateOnly(value: string | null | undefined): string { + if (!value) return '—'; + // Stored values are typically YYYY-MM-DD or ISO. Treat as date-only to avoid TZ shift. + const isoDate = value.length === 10 ? value + 'T00:00:00' : value; + const d = new Date(isoDate); + if (Number.isNaN(d.getTime())) return value; + return format(d, 'MMM d, yyyy'); +} + interface InvoiceDetailProps { invoiceId: string; } @@ -155,7 +197,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { Due Date -

{invoice.dueDate}

+

{formatDateOnly(invoice.dueDate)}

@@ -291,11 +333,11 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
Payment Date -

{invoice.paymentDate ?? '—'}

+

{formatDateOnly(invoice.paymentDate)}

Method -

{invoice.paymentMethod ?? '—'}

+

{formatPaymentMethod(invoice.paymentMethod)}

Reference @@ -325,11 +367,23 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
- +