Files
pn-new-crm/src/components/expenses/expense-detail.tsx
Matt Ciaccio 8699f81879
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
chore(style): codebase em-dash sweep + minor layout polish
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00

198 lines
6.7 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2, Receipt, Edit, Archive } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { apiFetch } from '@/lib/api/client';
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
import type { ExpenseRow } from './expense-columns';
import { ExpenseDuplicateBanner } from './expense-duplicate-banner';
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
paid: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
};
interface ExpenseDetailProps {
expenseId: string;
onEdit?: () => void;
onArchived?: () => void;
}
export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailProps) {
const queryClient = useQueryClient();
const [archiveOpen, setArchiveOpen] = useState(false);
const { data, isLoading, error } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', expenseId],
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
});
const { setChrome } = useMobileChrome();
const titleForChrome: string | null =
data?.data?.establishmentName ?? data?.data?.description?.slice(0, 40) ?? null;
useEffect(() => {
setChrome({ title: titleForChrome ?? 'Expense', showBackButton: true });
return () => setChrome({ title: null, showBackButton: false });
}, [titleForChrome, setChrome]);
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveOpen(false);
onArchived?.();
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">Failed to load expense details.</div>
);
}
const expense = data.data;
const status = expense.paymentStatus ?? 'unpaid';
const statusColor = PAYMENT_STATUS_COLORS[status] ?? '';
return (
<div className="space-y-6">
<ExpenseDuplicateBanner expense={expense} />
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">
{expense.establishmentName ?? 'Unnamed Expense'}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{format(new Date(expense.expenseDate), 'MMMM d, yyyy')}
</p>
</div>
<div className="flex items-center gap-2">
{onEdit && (
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="mr-1.5 h-4 w-4" />
Edit
</Button>
)}
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => setArchiveOpen(true)}
>
<Archive className="mr-1.5 h-4 w-4" />
Archive
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Amount</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold tabular-nums">
{Number(expense.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{expense.currency}
</p>
{expense.amountUsd && expense.currency !== 'USD' && (
<p className="text-sm text-muted-foreground mt-1">
$
{Number(expense.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
USD
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
{status}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Details</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">{expense.category?.replace(/_/g, ' ') ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">{expense.paymentMethod?.replace(/_/g, ' ') ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Payer</span>
<p className="mt-0.5">{expense.payer ?? '-'}</p>
</div>
<div>
<span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? '-'}</p>
</div>
</CardContent>
</Card>
{expense.receiptFileIds && expense.receiptFileIds.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Receipt className="h-4 w-4" />
Receipts ({expense.receiptFileIds.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(expense.receiptFileIds as string[]).map((fileId: string) => (
<Badge key={fileId} variant="secondary" className="font-mono text-xs">
{fileId}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={expense.establishmentName ?? 'this expense'}
entityType="Expense"
isArchived={false}
onConfirm={() => archiveMutation.mutate()}
isLoading={archiveMutation.isPending}
/>
</div>
);
}