fix(ui): humanize enum labels, format dates, resolve actor names, loading skeleton

- Documents hub signer status now renders via a label map (`Pending`,
  `Signed`, `Declined`, …) instead of the raw lowercase enum value.
- Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy`
  via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and
  swaps the "Payment Method" free-text input for a `Select` of labelled
  options (`Bank transfer`, `Credit card`, …) so we never store
  `bank_transfer` from a hand-typed field again.
- Interest tabs `MilestoneSection` status badge uses a `humanizeStatus`
  helper so values like `waiting_for_signatures` show as
  `Waiting For Signatures` (correctly title-cased) instead of being a
  lower-snake-case fragment inside an ALL-CAPS pill.
- `OUTCOME_BADGE` in the interest header now has a fall-through that
  renders any unknown outcome as a closed-state badge, preventing a
  closed interest from looking open just because its enum was added
  upstream without a matching label entry.
- Interest timeline route joins the `user` table and returns
  `userName` alongside `userId`; the client renders the resolved name
  instead of a 36-char UUID. Falls back to `'a teammate'` if the user
  row was deleted.
- Invoice "New / Step 3 — Review" replaces the truncated UUID display
  with a server-resolved client/company name via a small `useQuery`,
  so users can confirm they picked the right billing entity before
  submitting.
- New `loading.tsx` for client detail renders a header / tab strip /
  card skeleton during the server-component / initial-query window
  that previously flashed empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-02 23:01:35 +02:00
parent a391934b73
commit 57a099acc4
8 changed files with 204 additions and 19 deletions

View File

@@ -74,6 +74,15 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
rejected: 'rejected',
};
const SIGNER_STATUS_LABELS: Record<string, string> = {
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
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
</div>
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
{signer.status}
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
</StatusPill>
</li>
))}

View File

@@ -36,6 +36,20 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
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<string, string> = {
general_interest: 'General',
specific_qualified: 'Specific Qualified',
@@ -90,7 +104,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(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.

View File

@@ -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({
</div>
{status ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{status.replace(/_/g, ' ')}
{humanizeStatus(status)}
</span>
) : null}
</header>

View File

@@ -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<string, unknown>;
}
@@ -56,10 +58,14 @@ function eventIcon(event: TimelineEvent) {
return <Pencil className="h-4 w-4 text-muted-foreground" />;
}
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) {
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
{events.map((event) => {
const actor = actorLabel(event.userId);
const actor = actorLabel(event);
const isAuto = event.userId === 'system';
return (
<div key={event.id} className="relative flex gap-4 pb-6">

View File

@@ -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<string, string> = {
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<string, string> = {
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) {
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{invoice.dueDate}</p>
<p className="text-sm">{formatDateOnly(invoice.dueDate)}</p>
</CardContent>
</Card>
<Card>
@@ -291,11 +333,11 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Payment Date</span>
<p className="mt-0.5">{invoice.paymentDate ?? '—'}</p>
<p className="mt-0.5">{formatDateOnly(invoice.paymentDate)}</p>
</div>
<div>
<span className="text-muted-foreground">Method</span>
<p className="mt-0.5 capitalize">{invoice.paymentMethod ?? '—'}</p>
<p className="mt-0.5">{formatPaymentMethod(invoice.paymentMethod)}</p>
</div>
<div>
<span className="text-muted-foreground">Reference</span>
@@ -325,11 +367,23 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</div>
<div className="space-y-1">
<Label htmlFor="paymentMethod">Payment Method</Label>
<Input
id="paymentMethod"
placeholder="e.g. bank_transfer, credit_card"
{...paymentForm.register('paymentMethod')}
/>
<Select
value={paymentForm.watch('paymentMethod') ?? ''}
onValueChange={(v) =>
paymentForm.setValue('paymentMethod', v, { shouldValidate: true })
}
>
<SelectTrigger id="paymentMethod">
<SelectValue placeholder="Select a method" />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>