Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
183
src/components/expenses/expense-columns.tsx
Normal file
183
src/components/expenses/expense-columns.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { format } from 'date-fns';
|
||||
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export interface ExpenseRow {
|
||||
id: string;
|
||||
establishmentName: string | null;
|
||||
amount: string;
|
||||
currency: string;
|
||||
amountUsd: string | null;
|
||||
category: string | null;
|
||||
paymentStatus: string | null;
|
||||
paymentMethod: string | null;
|
||||
expenseDate: string;
|
||||
description: string | null;
|
||||
payer: string | null;
|
||||
receiptFileIds: string[] | null;
|
||||
archivedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const PAYMENT_STATUS_VARIANTS: Record<string, string> = {
|
||||
unpaid: 'destructive',
|
||||
paid: 'default',
|
||||
partial: 'secondary',
|
||||
};
|
||||
|
||||
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 GetColumnsOptions {
|
||||
portSlug: string;
|
||||
onEdit: (expense: ExpenseRow) => void;
|
||||
onArchive: (expense: ExpenseRow) => void;
|
||||
}
|
||||
|
||||
export function getExpenseColumns({
|
||||
portSlug,
|
||||
onEdit,
|
||||
onArchive,
|
||||
}: GetColumnsOptions): ColumnDef<ExpenseRow, unknown>[] {
|
||||
return [
|
||||
{
|
||||
id: 'expenseDate',
|
||||
accessorKey: 'expenseDate',
|
||||
header: 'Date',
|
||||
cell: ({ getValue }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{format(new Date(getValue() as string), 'MMM d, yyyy')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'establishmentName',
|
||||
accessorKey: 'establishmentName',
|
||||
header: 'Establishment',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
href={`/${portSlug}/expenses/${row.original.id}`}
|
||||
className="font-medium text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{row.original.establishmentName ?? '—'}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'amount',
|
||||
header: 'Amount',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium tabular-nums">
|
||||
{Number(row.original.amount).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{row.original.currency}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'amountUsd',
|
||||
header: 'USD Equiv.',
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.amountUsd ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
${Number(row.original.amountUsd).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">N/A</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'category',
|
||||
accessorKey: 'category',
|
||||
header: 'Category',
|
||||
cell: ({ getValue }) => {
|
||||
const cat = getValue() as string | null;
|
||||
if (!cat) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<Badge variant="outline" className="capitalize text-xs">
|
||||
{cat.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'paymentStatus',
|
||||
accessorKey: 'paymentStatus',
|
||||
header: 'Status',
|
||||
cell: ({ getValue }) => {
|
||||
const status = (getValue() as string | null) ?? 'unpaid';
|
||||
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize text-xs border ${colorClass}`}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
enableSorting: false,
|
||||
size: 48,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/${portSlug}/expenses/${row.original.id}`}>
|
||||
<Eye className="mr-2 h-3.5 w-3.5" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onEdit(row.original)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onArchive(row.original)}
|
||||
>
|
||||
<Archive className="mr-2 h-3.5 w-3.5" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user