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,142 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client';
import type { RequestReportInput } from '@/lib/validators/reports';
const REPORT_TYPE_LABELS: Record<string, string> = {
pipeline: 'Pipeline Summary',
revenue: 'Revenue Report',
activity: 'Activity Log',
occupancy: 'Berth Occupancy',
};
export function GenerateReportForm() {
const queryClient = useQueryClient();
const [reportType, setReportType] = useState<string>('');
const [name, setName] = useState<string>('');
const [dateFrom, setDateFrom] = useState<string>('');
const [dateTo, setDateTo] = useState<string>('');
const mutation = useMutation({
mutationFn: (data: RequestReportInput) =>
apiFetch('/api/v1/reports', { method: 'POST', body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reports'] });
setReportType('');
setName('');
setDateFrom('');
setDateTo('');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!reportType || !name.trim()) return;
const payload: RequestReportInput = {
reportType: reportType as RequestReportInput['reportType'],
name: name.trim(),
parameters: {
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
};
mutation.mutate(payload);
};
return (
<Card>
<CardHeader>
<CardTitle>Generate Report</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="reportType">Report Type</Label>
<Select value={reportType} onValueChange={setReportType}>
<SelectTrigger id="reportType">
<SelectValue placeholder="Select a report type..." />
</SelectTrigger>
<SelectContent>
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="name">Report Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Pipeline Summary Q1 2025"
maxLength={200}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="dateFrom">Date From (optional)</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dateTo">Date To (optional)</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
</div>
{mutation.isError && (
<p className="text-sm text-destructive">
{mutation.error instanceof Error
? mutation.error.message
: 'Failed to queue report. Please try again.'}
</p>
)}
{mutation.isSuccess && (
<p className="text-sm text-green-600">
Report queued successfully. You will be notified when it is ready.
</p>
)}
<Button
type="submit"
disabled={!reportType || !name.trim() || mutation.isPending}
>
{mutation.isPending ? 'Queuing...' : 'Generate Report'}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
type ReportStatus = 'queued' | 'processing' | 'ready' | 'failed';
interface ReportStatusBadgeProps {
status: ReportStatus;
}
export function ReportStatusBadge({ status }: ReportStatusBadgeProps) {
switch (status) {
case 'queued':
return <Badge variant="outline">Queued</Badge>;
case 'processing':
return (
<Badge variant="secondary" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Processing
</Badge>
);
case 'ready':
return (
<Badge className="bg-green-600 text-white hover:bg-green-700">
Ready
</Badge>
);
case 'failed':
return <Badge variant="destructive">Failed</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
import { apiFetch } from '@/lib/api/client';
interface GeneratedReport {
id: string;
name: string;
reportType: string;
status: 'queued' | 'processing' | 'ready' | 'failed';
requestedBy: string;
createdAt: string;
completedAt: string | null;
errorMessage: string | null;
fileId: string | null;
}
interface ReportsResponse {
data: GeneratedReport[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
const REPORT_TYPE_LABELS: Record<string, string> = {
pipeline: 'Pipeline Summary',
revenue: 'Revenue',
activity: 'Activity Log',
occupancy: 'Berth Occupancy',
};
export function ReportsList() {
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const { data, isLoading } = useQuery<ReportsResponse>({
queryKey: ['reports'],
queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'),
refetchInterval: (query) => {
const rows = query.state.data?.data ?? [];
const hasPending = rows.some(
(r) => r.status === 'queued' || r.status === 'processing',
);
return hasPending ? 5000 : false;
},
});
const handleDownload = async (reportId: string) => {
setDownloadingId(reportId);
try {
const result = await apiFetch<{ url: string }>(
`/api/v1/reports/${reportId}/download`,
);
window.open(result.url, '_blank');
} catch (err) {
console.error('Download failed', err);
} finally {
setDownloadingId(null);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Generated Reports</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !data?.data.length ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">
No reports generated yet
</p>
<p className="text-xs text-muted-foreground">
Use the form above to generate your first report.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Requested</TableHead>
<TableHead>Completed</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.data.map((report) => (
<TableRow key={report.id}>
<TableCell className="font-medium">{report.name}</TableCell>
<TableCell className="text-muted-foreground">
{REPORT_TYPE_LABELS[report.reportType] ?? report.reportType}
</TableCell>
<TableCell>
<ReportStatusBadge status={report.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(report.createdAt).toLocaleString('en-GB', {
dateStyle: 'short',
timeStyle: 'short',
})}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{report.completedAt
? new Date(report.completedAt).toLocaleString('en-GB', {
dateStyle: 'short',
timeStyle: 'short',
})
: report.status === 'failed' && report.errorMessage
? (
<span className="text-destructive text-xs" title={report.errorMessage}>
Failed
</span>
)
: '—'}
</TableCell>
<TableCell className="text-right">
{report.status === 'ready' && report.fileId && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(report.id)}
disabled={downloadingId === report.id}
>
<Download className="mr-1 h-4 w-4" />
{downloadingId === report.id ? 'Opening...' : 'Download'}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { GenerateReportForm } from '@/components/reports/generate-report-form';
import { ReportsList } from '@/components/reports/reports-list';
export function ReportsPageClient() {
useRealtimeInvalidation({
'report:queued': [['reports']],
'report:ready': [['reports']],
'report:failed': [['reports']],
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Reports</h1>
<p className="text-muted-foreground">
Generate and download port reports as PDF documents
</p>
</div>
<GenerateReportForm />
<ReportsList />
</div>
);
}