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:
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user