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>
188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
'use client';
|
|
|
|
import Link from 'next/link';
|
|
import { format } from 'date-fns';
|
|
import { MoreHorizontal, Eye, Send, CreditCard, Trash2, FileText } 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 InvoiceRow {
|
|
id: string;
|
|
invoiceNumber: string;
|
|
clientName: string;
|
|
total: string;
|
|
currency: string;
|
|
status: string;
|
|
paymentStatus: string | null;
|
|
dueDate: string;
|
|
pdfFileId: string | null;
|
|
archivedAt: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
draft: 'bg-gray-100 text-gray-700 border-gray-200',
|
|
sent: 'bg-blue-100 text-blue-700 border-blue-200',
|
|
paid: 'bg-green-100 text-green-700 border-green-200',
|
|
overdue: 'bg-red-100 text-red-700 border-red-200',
|
|
cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
|
|
};
|
|
|
|
interface GetColumnsOptions {
|
|
portSlug: string;
|
|
onSend?: (invoice: InvoiceRow) => void;
|
|
onRecordPayment?: (invoice: InvoiceRow) => void;
|
|
onDelete?: (invoice: InvoiceRow) => void;
|
|
}
|
|
|
|
export function getInvoiceColumns({
|
|
portSlug,
|
|
onSend,
|
|
onRecordPayment,
|
|
onDelete,
|
|
}: GetColumnsOptions): ColumnDef<InvoiceRow, unknown>[] {
|
|
const today = new Date().toISOString().split('T')[0]!;
|
|
|
|
return [
|
|
{
|
|
id: 'invoiceNumber',
|
|
accessorKey: 'invoiceNumber',
|
|
header: 'Invoice #',
|
|
cell: ({ row }) => (
|
|
<Link
|
|
href={`/${portSlug}/invoices/${row.original.id}`}
|
|
className="font-medium text-primary hover:underline font-mono text-sm"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{row.original.invoiceNumber}
|
|
</Link>
|
|
),
|
|
},
|
|
{
|
|
id: 'clientName',
|
|
accessorKey: 'clientName',
|
|
header: 'Client',
|
|
cell: ({ getValue }) => (
|
|
<span className="font-medium">{getValue() as string}</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'total',
|
|
header: 'Total',
|
|
enableSorting: false,
|
|
cell: ({ row }) => (
|
|
<span className="font-medium tabular-nums">
|
|
{Number(row.original.total).toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2,
|
|
})}{' '}
|
|
{row.original.currency}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
id: 'status',
|
|
accessorKey: 'status',
|
|
header: 'Status',
|
|
cell: ({ getValue }) => {
|
|
const status = (getValue() as string) ?? 'draft';
|
|
const colorClass = STATUS_COLORS[status] ?? STATUS_COLORS.draft;
|
|
return (
|
|
<Badge
|
|
variant="outline"
|
|
className={`capitalize text-xs border ${colorClass}`}
|
|
>
|
|
{status}
|
|
</Badge>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'dueDate',
|
|
accessorKey: 'dueDate',
|
|
header: 'Due Date',
|
|
cell: ({ row }) => {
|
|
const due = row.original.dueDate;
|
|
const isOverdue =
|
|
row.original.status === 'sent' && due < today;
|
|
return (
|
|
<span
|
|
className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-muted-foreground'}`}
|
|
>
|
|
{format(new Date(due), 'MMM d, yyyy')}
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
id: 'actions',
|
|
header: '',
|
|
enableSorting: false,
|
|
size: 48,
|
|
cell: ({ row }) => {
|
|
const invoice = row.original;
|
|
return (
|
|
<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}/invoices/${invoice.id}`}>
|
|
<Eye className="mr-2 h-3.5 w-3.5" />
|
|
View
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
{invoice.pdfFileId && (
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
|
|
<FileText className="mr-2 h-3.5 w-3.5" />
|
|
View PDF
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
)}
|
|
{invoice.status === 'draft' && onSend && (
|
|
<DropdownMenuItem onClick={() => onSend(invoice)}>
|
|
<Send className="mr-2 h-3.5 w-3.5" />
|
|
Send
|
|
</DropdownMenuItem>
|
|
)}
|
|
{(invoice.status === 'sent' || invoice.status === 'overdue') &&
|
|
onRecordPayment && (
|
|
<DropdownMenuItem onClick={() => onRecordPayment(invoice)}>
|
|
<CreditCard className="mr-2 h-3.5 w-3.5" />
|
|
Record Payment
|
|
</DropdownMenuItem>
|
|
)}
|
|
{invoice.status === 'draft' && onDelete && (
|
|
<DropdownMenuItem
|
|
className="text-destructive"
|
|
onClick={() => onDelete(invoice)}
|
|
>
|
|
<Trash2 className="mr-2 h-3.5 w-3.5" />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
}
|