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

@@ -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<string, string> = {
@@ -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<string, unknown>;
}
@@ -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<string, string>(
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<string, unknown>) ?? {},
}));
@@ -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<string, unknown>) ?? {},
};
@@ -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 },
});