Files
pn-new-crm/src/components/invoices/invoice-columns.tsx
Matt 67d7e6e3d5
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled
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>
2026-03-26 11:52:51 +01:00

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>
);
},
},
];
}