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,40 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { ExpenseDetail } from '@/components/expenses/expense-detail';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from '@/components/expenses/expense-columns';
export default function ExpenseDetailPage() {
const params = useParams<{ portSlug: string; id: string }>();
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const { data } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', params.id],
queryFn: () => apiFetch(`/api/v1/expenses/${params.id}`),
enabled: !!params.id,
});
return (
<div className="max-w-3xl mx-auto">
<ExpenseDetail
expenseId={params.id}
onEdit={() => setEditOpen(true)}
onArchived={() => router.push(`/${params.portSlug}/expenses`)}
/>
{data?.data && (
<ExpenseFormDialog
open={editOpen}
onOpenChange={setEditOpen}
expense={data.data}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { Plus, Download, FileText, FileSpreadsheet } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { ExpenseFormDialog } from '@/components/expenses/expense-form-dialog';
import { expenseFilterDefinitions } from '@/components/expenses/expense-filters';
import { getExpenseColumns, type ExpenseRow } from '@/components/expenses/expense-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export default function ExpensesPage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editExpense, setEditExpense] = useState<ExpenseRow | null>(null);
const [archiveExpense, setArchiveExpense] = useState<ExpenseRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<ExpenseRow>({
queryKey: ['expenses'],
endpoint: '/api/v1/expenses',
filterDefinitions: expenseFilterDefinitions,
});
useRealtimeInvalidation({
'expense:created': [['expenses']],
'expense:updated': [['expenses']],
'expense:archived': [['expenses']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/expenses/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveExpense(null);
},
});
async function handleExport(type: 'csv' | 'pdf') {
const res = await fetch(`/api/v1/expenses/export/${type}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(filters),
credentials: 'include',
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `expenses.${type}`;
a.click();
URL.revokeObjectURL(url);
}
const columns = getExpenseColumns({
portSlug,
onEdit: (expense) => setEditExpense(expense),
onArchive: (expense) => setArchiveExpense(expense),
});
return (
<div className="space-y-4">
<PageHeader
title="Expenses"
description="Track and manage port expenses"
actions={
<div className="flex items-center gap-2">
<PermissionGate resource="expenses" action="view">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-4 w-4" />
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleExport('csv')}>
<FileSpreadsheet className="mr-2 h-4 w-4" />
Export CSV
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleExport('pdf')}>
<FileText className="mr-2 h-4 w-4" />
Export PDF
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</PermissionGate>
<PermissionGate resource="expenses" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Expense
</Button>
</PermissionGate>
</div>
}
/>
<FilterBar
filters={expenseFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No expenses found"
description="Get started by adding your first expense."
action={{ label: 'New Expense', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<ExpenseFormDialog open={createOpen} onOpenChange={setCreateOpen} />
{editExpense && (
<ExpenseFormDialog
open={!!editExpense}
onOpenChange={(open) => !open && setEditExpense(null)}
expense={editExpense}
/>
)}
<ArchiveConfirmDialog
open={!!archiveExpense}
onOpenChange={(open) => !open && setArchiveExpense(null)}
entityName={archiveExpense?.establishmentName ?? 'this expense'}
entityType="Expense"
isArchived={false}
onConfirm={() => archiveExpense && archiveMutation.mutate(archiveExpense.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,252 @@
'use client';
import { useState, useRef } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useMutation } from '@tanstack/react-query';
import { Upload, Loader2, Camera, ScanLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
import { EXPENSE_CATEGORIES } from '@/lib/constants';
interface ScanResult {
establishment: string | null;
date: string | null;
amount: number | null;
currency: string | null;
lineItems: Array<{ description: string; amount: number }>;
confidence: number;
}
export default function ScanReceiptPage() {
const params = useParams<{ portSlug: string }>();
const router = useRouter();
const fileInputRef = useRef<HTMLInputElement>(null);
const [scanResult, setScanResult] = useState<ScanResult | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
// Editable fields from scan
const [establishment, setEstablishment] = useState('');
const [amount, setAmount] = useState('');
const [currency, setCurrency] = useState('USD');
const [date, setDate] = useState('');
const [category, setCategory] = useState('');
const scanMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/v1/expenses/scan-receipt', {
method: 'POST',
body: formData,
credentials: 'include',
});
if (!res.ok) throw new Error('Scan failed');
return res.json() as Promise<{ data: ScanResult }>;
},
onSuccess: (response) => {
const result = response.data;
setScanResult(result);
if (result.establishment) setEstablishment(result.establishment);
if (result.amount) setAmount(String(result.amount));
if (result.currency) setCurrency(result.currency);
if (result.date) setDate(result.date.split('T')[0] ?? result.date);
},
});
const saveMutation = useMutation({
mutationFn: () =>
apiFetch('/api/v1/expenses', {
method: 'POST',
body: {
establishmentName: establishment,
amount: Number(amount),
currency,
category: category || undefined,
expenseDate: date ? new Date(date) : new Date(),
paymentStatus: 'unpaid',
},
}),
onSuccess: () => {
router.push(`/${params.portSlug}/expenses`);
},
});
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setPreviewUrl(url);
scanMutation.mutate(file);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Scan Receipt</h1>
<p className="text-muted-foreground mt-1">
Upload a receipt image and we will extract the expense details automatically.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ScanLine className="h-4 w-4" />
Upload Receipt
</CardTitle>
</CardHeader>
<CardContent>
<div
className="border-2 border-dashed rounded-lg p-8 text-center cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
>
{previewUrl ? (
<img
src={previewUrl}
alt="Receipt preview"
className="max-h-64 mx-auto rounded object-contain"
/>
) : (
<div className="space-y-2">
<Upload className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Click to upload or drag and drop
</p>
<p className="text-xs text-muted-foreground">
JPEG, PNG, WebP up to 10MB
</p>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
{scanMutation.isPending && (
<div className="flex items-center justify-center gap-2 mt-4 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">Scanning receipt...</span>
</div>
)}
</CardContent>
</Card>
{(scanResult || scanMutation.isSuccess) && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Extracted Details
{scanResult && (
<span className="text-sm font-normal text-muted-foreground ml-2">
(confidence: {Math.round((scanResult.confidence ?? 0) * 100)}%)
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="scan-amount">Amount</Label>
<Input
id="scan-amount"
type="number"
step="0.01"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.00"
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-currency">Currency</Label>
<Input
id="scan-currency"
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
maxLength={3}
placeholder="USD"
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="scan-establishment">Establishment</Label>
<Input
id="scan-establishment"
value={establishment}
onChange={(e) => setEstablishment(e.target.value)}
placeholder="Establishment name"
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-date">Date</Label>
<Input
id="scan-date"
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="scan-category">Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger id="scan-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>
{saveMutation.isError && (
<p className="text-sm text-destructive">
{(saveMutation.error as Error).message}
</p>
)}
<div className="flex gap-2 pt-2">
<Button
variant="outline"
onClick={() => router.push(`/${params.portSlug}/expenses`)}
>
Cancel
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !amount}
>
{saveMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
Save as Expense
</Button>
</div>
</CardContent>
</Card>
)}
</div>
);
}