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:
@@ -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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header strip — title, badges, action buttons */}
|
||||||
|
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="h-5 w-16 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-20 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab strip */}
|
||||||
|
<div className="flex gap-2 border-b border-border pb-1">
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-8 w-20 rounded-md" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column overview */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<CardSkeleton />
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -97,6 +97,28 @@ export default function NewInvoicePage() {
|
|||||||
const watchedValues = watch();
|
const watchedValues = watch();
|
||||||
const isDepositInvoice = watchedValues.kind === 'deposit';
|
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.
|
// Pre-fill the billing entity from the linked interest's client on launch.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
||||||
@@ -356,9 +378,13 @@ export default function NewInvoicePage() {
|
|||||||
<p className="font-medium mt-0.5">
|
<p className="font-medium mt-0.5">
|
||||||
{watchedValues.billingEntity ? (
|
{watchedValues.billingEntity ? (
|
||||||
<>
|
<>
|
||||||
<span className="capitalize">{watchedValues.billingEntity.type}</span>{' '}
|
{billingEntityName?.name ? (
|
||||||
<span className="text-xs opacity-60">
|
<span>{billingEntityName.name}</span>
|
||||||
{watchedValues.billingEntity.id.slice(0, 12)}
|
) : (
|
||||||
|
<span className="text-muted-foreground">Loading…</span>
|
||||||
|
)}{' '}
|
||||||
|
<span className="text-xs text-muted-foreground capitalize">
|
||||||
|
({watchedValues.billingEntity.type})
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { db } from '@/lib/db';
|
|||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { auditLogs } from '@/lib/db/schema/system';
|
import { auditLogs } from '@/lib/db/schema/system';
|
||||||
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
import { documents, documentEvents } from '@/lib/db/schema/documents';
|
||||||
|
import { user } from '@/lib/db/schema/users';
|
||||||
import { stageLabel } from '@/lib/constants';
|
import { stageLabel } from '@/lib/constants';
|
||||||
|
|
||||||
const OUTCOME_LABELS: Record<string, string> = {
|
const OUTCOME_LABELS: Record<string, string> = {
|
||||||
@@ -33,6 +34,10 @@ interface TimelineEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
userId: string | null;
|
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;
|
createdAt: Date;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -81,6 +86,27 @@ export const GET = withAuth(
|
|||||||
|
|
||||||
const docTitles = Object.fromEntries(interestDocs.map((d) => [d.id, d.title]));
|
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
|
// Union and sort
|
||||||
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
const auditEvents: TimelineEvent[] = auditRows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -93,6 +119,7 @@ export const GET = withAuth(
|
|||||||
row.userId,
|
row.userId,
|
||||||
),
|
),
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
|
userName: resolveUserName(row.userId),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
metadata: (row.metadata as Record<string, unknown>) ?? {},
|
||||||
}));
|
}));
|
||||||
@@ -106,6 +133,7 @@ export const GET = withAuth(
|
|||||||
action: row.eventType,
|
action: row.eventType,
|
||||||
description: `Document "${title}" ${action}`,
|
description: `Document "${title}" ${action}`,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
userName: null,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
metadata: (row.eventData as Record<string, unknown>) ?? {},
|
||||||
};
|
};
|
||||||
@@ -128,6 +156,7 @@ export const GET = withAuth(
|
|||||||
description:
|
description:
|
||||||
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
interest.pipelineStage === 'open' ? 'Interest created' : `Interest created at ${stage}`,
|
||||||
userId: null,
|
userId: null,
|
||||||
|
userName: null,
|
||||||
createdAt: created,
|
createdAt: created,
|
||||||
metadata: { synthetic: true },
|
metadata: { synthetic: true },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ const STATUS_PILL_MAP: Record<string, StatusPillStatus> = {
|
|||||||
rejected: 'rejected',
|
rejected: 'rejected',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SIGNER_STATUS_LABELS: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
sent: 'Sent',
|
||||||
|
signed: 'Signed',
|
||||||
|
declined: 'Declined',
|
||||||
|
expired: 'Expired',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
interface DocumentsHubProps {
|
interface DocumentsHubProps {
|
||||||
portSlug: string;
|
portSlug: string;
|
||||||
initialTab?: DocumentsHubTab;
|
initialTab?: DocumentsHubTab;
|
||||||
@@ -187,7 +196,7 @@ export function DocumentsHub({ portSlug, initialTab = 'all' }: DocumentsHubProps
|
|||||||
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
<span className="truncate text-muted-foreground">{signer.signerEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
<StatusPill status={STATUS_PILL_MAP[signer.status] ?? 'pending'}>
|
||||||
{signer.status}
|
{SIGNER_STATUS_LABELS[signer.status] ?? signer.status}
|
||||||
</StatusPill>
|
</StatusPill>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -36,6 +36,20 @@ const OUTCOME_BADGE: Record<string, { label: string; className: string }> = {
|
|||||||
cancelled: { label: 'Cancelled', className: 'bg-slate-200 text-slate-700' },
|
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> = {
|
const CATEGORY_LABELS: Record<string, string> = {
|
||||||
general_interest: 'General',
|
general_interest: 'General',
|
||||||
specific_qualified: 'Specific Qualified',
|
specific_qualified: 'Specific Qualified',
|
||||||
@@ -90,7 +104,7 @@ export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeade
|
|||||||
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
const [outcomeDialog, setOutcomeDialog] = useState<null | 'won' | 'lost'>(null);
|
||||||
|
|
||||||
const isArchived = !!interest.archivedAt;
|
const isArchived = !!interest.archivedAt;
|
||||||
const outcomeBadge = interest.outcome ? OUTCOME_BADGE[interest.outcome] : null;
|
const outcomeBadge = resolveOutcomeBadge(interest.outcome);
|
||||||
const isClosed = !!interest.outcome;
|
const isClosed = !!interest.outcome;
|
||||||
|
|
||||||
// Contact deep-links — resolved from the linked client's primary channels.
|
// 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()),
|
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 {
|
interface InterestTabsOptions {
|
||||||
interestId: string;
|
interestId: string;
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
@@ -203,7 +209,7 @@ function MilestoneSection({
|
|||||||
</div>
|
</div>
|
||||||
{status ? (
|
{status ? (
|
||||||
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<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>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface TimelineEvent {
|
|||||||
action: string;
|
action: string;
|
||||||
description: string;
|
description: string;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
|
/** Resolved display name (server-side join). Falls back to userId when null. */
|
||||||
|
userName?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
metadata: Record<string, unknown>;
|
metadata: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
@@ -56,10 +58,14 @@ function eventIcon(event: TimelineEvent) {
|
|||||||
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
return <Pencil className="h-4 w-4 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function actorLabel(userId: string | null): string | null {
|
function actorLabel(event: TimelineEvent): string | null {
|
||||||
if (!userId) return null;
|
if (event.userName) return event.userName;
|
||||||
if (userId === 'system') return 'system';
|
if (!event.userId) return null;
|
||||||
return userId;
|
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) {
|
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" />
|
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
|
||||||
|
|
||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
const actor = actorLabel(event.userId);
|
const actor = actorLabel(event);
|
||||||
const isAuto = event.userId === 'system';
|
const isAuto = event.userId === 'system';
|
||||||
return (
|
return (
|
||||||
<div key={event.id} className="relative flex gap-4 pb-6">
|
<div key={event.id} className="relative flex gap-4 pb-6">
|
||||||
|
|||||||
@@ -6,12 +6,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { Loader2, Send, CreditCard } from 'lucide-react';
|
import { Loader2, Send, CreditCard } from 'lucide-react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { InvoicePdfPreview } from './invoice-pdf-preview';
|
import { InvoicePdfPreview } from './invoice-pdf-preview';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
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',
|
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 {
|
interface InvoiceDetailProps {
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
}
|
}
|
||||||
@@ -155,7 +197,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
|
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm">{invoice.dueDate}</p>
|
<p className="text-sm">{formatDateOnly(invoice.dueDate)}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -291,11 +333,11 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Payment Date</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Method</span>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Reference</span>
|
<span className="text-muted-foreground">Reference</span>
|
||||||
@@ -325,11 +367,23 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="paymentMethod">Payment Method</Label>
|
<Label htmlFor="paymentMethod">Payment Method</Label>
|
||||||
<Input
|
<Select
|
||||||
id="paymentMethod"
|
value={paymentForm.watch('paymentMethod') ?? ''}
|
||||||
placeholder="e.g. bank_transfer, credit_card"
|
onValueChange={(v) =>
|
||||||
{...paymentForm.register('paymentMethod')}
|
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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>
|
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user