Initial commit: Port Nimara CRM (Layers 0-4)
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

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:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

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

View File

@@ -0,0 +1,203 @@
'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 { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
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>
);
}

View File

@@ -0,0 +1,53 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
import { EXPENSE_CATEGORIES } from '@/lib/constants';
export const expenseFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by establishment or description...',
},
{
key: 'category',
label: 'Category',
type: 'multi-select',
options: EXPENSE_CATEGORIES.map((c) => ({
label: c.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
value: c,
})),
},
{
key: 'paymentStatus',
label: 'Payment Status',
type: 'select',
options: [
{ label: 'Unpaid', value: 'unpaid' },
{ label: 'Paid', value: 'paid' },
{ label: 'Partial', value: 'partial' },
],
},
{
key: 'dateFrom',
label: 'Date From',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'dateTo',
label: 'Date To',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'currency',
label: 'Currency',
type: 'text',
placeholder: 'e.g. USD, EUR',
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,261 @@
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
import type { ExpenseRow } from './expense-columns';
interface ExpenseFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
expense?: ExpenseRow;
}
export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) {
const queryClient = useQueryClient();
const isEdit = !!expense;
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateExpenseInput>({
resolver: zodResolver(createExpenseSchema),
defaultValues: {
currency: 'USD',
paymentStatus: 'unpaid',
},
});
useEffect(() => {
if (open && expense) {
reset({
establishmentName: expense.establishmentName ?? undefined,
amount: Number(expense.amount),
currency: expense.currency,
category: expense.category as any,
paymentMethod: expense.paymentMethod as any,
payer: expense.payer ?? undefined,
expenseDate: new Date(expense.expenseDate),
paymentStatus: (expense.paymentStatus as any) ?? 'unpaid',
});
} else if (open && !expense) {
reset({
currency: 'USD',
paymentStatus: 'unpaid',
expenseDate: new Date(),
});
}
}, [open, expense, reset]);
const mutation = useMutation({
mutationFn: (data: CreateExpenseInput) => {
if (isEdit) {
return apiFetch(`/api/v1/expenses/${expense.id}`, {
method: 'PATCH',
body: data,
});
}
return apiFetch('/api/v1/expenses', { method: 'POST', body: data });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
onOpenChange(false);
},
});
function onSubmit(data: CreateExpenseInput) {
mutation.mutate(data);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Expense' : 'New Expense'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="expenseDate">Date *</Label>
<Input
id="expenseDate"
type="date"
{...register('expenseDate', {
setValueAs: (v) => (v ? new Date(v) : undefined),
})}
defaultValue={expense?.expenseDate
? new Date(expense.expenseDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0]}
/>
{errors.expenseDate && (
<p className="text-xs text-destructive">{errors.expenseDate.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="amount">Amount *</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
{...register('amount', { valueAsNumber: true })}
/>
{errors.amount && (
<p className="text-xs text-destructive">{errors.amount.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
placeholder="USD"
maxLength={3}
{...register('currency')}
/>
{errors.currency && (
<p className="text-xs text-destructive">{errors.currency.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="establishmentName">Establishment</Label>
<Input
id="establishmentName"
placeholder="Establishment name"
{...register('establishmentName')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
onValueChange={(v) => setValue('category', v as any)}
defaultValue={expense?.category ?? undefined}
>
<SelectTrigger id="category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="paymentMethod">Payment Method</Label>
<Select
onValueChange={(v) => setValue('paymentMethod', v as any)}
defaultValue={expense?.paymentMethod ?? undefined}
>
<SelectTrigger id="paymentMethod">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="payer">Payer</Label>
<Input
id="payer"
placeholder="Who paid?"
{...register('payer')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="paymentStatus">Payment Status</Label>
<Select
onValueChange={(v) => setValue('paymentStatus', v as any)}
defaultValue={expense?.paymentStatus ?? 'unpaid'}
>
<SelectTrigger id="paymentStatus">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unpaid">Unpaid</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="partial">Partial</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Additional notes..."
rows={3}
{...register('description')}
/>
</div>
{mutation.isError && (
<p className="text-sm text-destructive">
{(mutation.error as Error).message}
</p>
)}
<SheetFooter className="pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Expense'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}