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) {
Payment Method
-
+
+ paymentForm.setValue('paymentMethod', v, { shouldValidate: true })
+ }
+ >
+
+
+
+
+ {PAYMENT_METHOD_OPTIONS.map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
Reference / Transaction ID