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:
187
src/components/invoices/invoice-columns.tsx
Normal file
187
src/components/invoices/invoice-columns.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user