194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState } 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 type { ExpenseRow } from './expense-columns';
|
||
|
|
|
||
|
|
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 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">
|
||
|
|
<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>
|
||
|
|
);
|
||
|
|
}
|