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,149 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { apiFetch } from '@/lib/api/client';
interface DocumentRow {
id: string;
documentType: string;
title: string;
status: string;
createdAt: string;
signers?: Array<{ status: string }>;
}
interface DocumentListProps {
interestId?: string;
clientId?: string;
}
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
draft: 'secondary',
sent: 'default',
partially_signed: 'default',
completed: 'outline',
expired: 'destructive',
cancelled: 'destructive',
};
const TYPE_LABELS: Record<string, string> = {
eoi: 'EOI',
contract: 'Contract',
nda: 'NDA',
reservation_agreement: 'Reservation',
other: 'Other',
};
export function DocumentList({ interestId, clientId }: DocumentListProps) {
const queryClient = useQueryClient();
const queryParams = new URLSearchParams();
if (interestId) queryParams.set('interestId', interestId);
if (clientId) queryParams.set('clientId', clientId);
const { data, isLoading } = usePaginatedQuery<DocumentRow>({
queryKey: ['documents', { interestId, clientId }],
endpoint: `/api/v1/documents?${queryParams.toString()}`,
filterDefinitions: [],
});
const handleDelete = async (doc: DocumentRow) => {
if (!confirm(`Delete "${doc.title}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/documents/${doc.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] });
} catch {
// silent
}
};
const handleSend = async (doc: DocumentRow) => {
try {
await apiFetch(`/api/v1/documents/${doc.id}/send`, { method: 'POST' });
queryClient.invalidateQueries({ queryKey: ['documents', { interestId, clientId }] });
} catch {
// silent
}
};
const getSignerProgress = (doc: DocumentRow) => {
if (!doc.signers) return '—';
const signed = doc.signers.filter((s) => s.status === 'signed').length;
return `${signed}/${doc.signers.length} signed`;
};
if (isLoading) {
return <div className="py-8 text-center text-sm text-muted-foreground">Loading documents...</div>;
}
if (!data || data.length === 0) {
return <div className="py-8 text-center text-sm text-muted-foreground">No documents yet.</div>;
}
return (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">Type</th>
<th className="px-4 py-3 text-left font-medium">Title</th>
<th className="px-4 py-3 text-left font-medium">Status</th>
<th className="px-4 py-3 text-left font-medium">Signers</th>
<th className="px-4 py-3 text-left font-medium">Created</th>
<th className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.map((doc) => (
<tr key={doc.id} className="border-b last:border-0 hover:bg-muted/20">
<td className="px-4 py-3">
<Badge variant="outline">{TYPE_LABELS[doc.documentType] ?? doc.documentType}</Badge>
</td>
<td className="px-4 py-3 font-medium">{doc.title}</td>
<td className="px-4 py-3">
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>{doc.status}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">{getSignerProgress(doc)}</td>
<td className="px-4 py-3 text-muted-foreground">
{new Date(doc.createdAt).toLocaleDateString('en-GB')}
</td>
<td className="px-4 py-3 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
&hellip;
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{doc.status === 'draft' && (
<DropdownMenuItem onClick={() => handleSend(doc)}>
Send for Signing
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => handleDelete(doc)}
className="text-destructive focus:text-destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,115 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface EoiPrerequisites {
hasName: boolean;
hasEmail: boolean;
hasYachtDims: boolean;
hasBerth: boolean;
}
interface EoiGenerateDialogProps {
interestId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
prerequisites: EoiPrerequisites;
}
const PREREQUISITE_LABELS: { key: keyof EoiPrerequisites; label: string }[] = [
{ key: 'hasName', label: 'Client has full name' },
{ key: 'hasEmail', label: 'Client has email address' },
{ key: 'hasYachtDims', label: 'Yacht dimensions set' },
{ key: 'hasBerth', label: 'Berth linked to interest' },
];
export function EoiGenerateDialog({
interestId,
open,
onOpenChange,
prerequisites,
}: EoiGenerateDialogProps) {
const queryClient = useQueryClient();
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
const allMet = Object.values(prerequisites).every(Boolean);
const handleGenerate = async () => {
if (!allMet) return;
setIsGenerating(true);
setError(null);
try {
await apiFetch('/api/v1/documents/generate-eoi', {
method: 'POST',
body: { interestId },
});
queryClient.invalidateQueries({ queryKey: ['documents', { interestId }] });
onOpenChange(false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate EOI');
} finally {
setIsGenerating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Generate Expression of Interest</DialogTitle>
<DialogDescription>
The following prerequisites must be met before generating the EOI document.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
{PREREQUISITE_LABELS.map(({ key, label }) => (
<div key={key} className="flex items-center gap-3">
<span
className={`flex h-5 w-5 items-center justify-center rounded-full text-xs font-bold ${
prerequisites[key]
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{prerequisites[key] ? '✓' : '✗'}
</span>
<span className={prerequisites[key] ? 'text-foreground' : 'text-muted-foreground'}>
{label}
</span>
</div>
))}
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleGenerate} disabled={!allMet || isGenerating}>
{isGenerating ? 'Generating...' : 'Generate EOI'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { apiFetch } from '@/lib/api/client';
import { useQueryClient } from '@tanstack/react-query';
interface Signer {
id: string;
signerName: string;
signerEmail: string;
signerRole: string;
signingOrder: number;
status: string;
signedAt?: string | null;
}
interface SigningProgressProps {
documentId: string;
signers: Signer[];
}
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-muted border-muted-foreground/30 text-muted-foreground',
signed: 'bg-green-100 border-green-500 text-green-800',
declined: 'bg-red-100 border-red-500 text-red-800',
};
const STATUS_LABELS: Record<string, string> = {
pending: 'Pending',
signed: 'Signed',
declined: 'Declined',
};
const ROLE_LABELS: Record<string, string> = {
client: 'Client',
developer: 'Developer',
approver: 'Sales/Approver',
};
export function SigningProgress({ documentId, signers }: SigningProgressProps) {
const queryClient = useQueryClient();
const sorted = [...signers].sort((a, b) => a.signingOrder - b.signingOrder);
const handleResend = async (signer: Signer) => {
try {
await apiFetch(`/api/v1/documents/${documentId}/remind`, { method: 'POST' });
queryClient.invalidateQueries({ queryKey: ['documents', documentId, 'signers'] });
} catch {
// silent
}
};
return (
<div className="flex items-start gap-2">
{sorted.map((signer, idx) => (
<div key={signer.id} className="flex items-center gap-2">
<div className="flex flex-col items-center gap-1">
<div
className={`flex h-10 w-10 items-center justify-center rounded-full border-2 text-xs font-bold ${STATUS_COLORS[signer.status] ?? STATUS_COLORS.pending}`}
>
{signer.signingOrder}
</div>
<div className="max-w-24 text-center">
<p className="truncate text-xs font-medium">{signer.signerName}</p>
<p className="truncate text-xs text-muted-foreground">
{ROLE_LABELS[signer.signerRole] ?? signer.signerRole}
</p>
<p className="text-xs text-muted-foreground">
{STATUS_LABELS[signer.status] ?? signer.status}
</p>
{signer.signedAt && (
<p className="text-xs text-muted-foreground">
{new Date(signer.signedAt).toLocaleDateString('en-GB')}
</p>
)}
{signer.status === 'pending' && (
<button
onClick={() => handleResend(signer)}
className="mt-1 text-xs text-primary underline hover:no-underline"
>
Resend
</button>
)}
</div>
</div>
{idx < sorted.length - 1 && (
<div className="mb-6 h-0.5 w-8 flex-shrink-0 bg-border" />
)}
</div>
))}
</div>
);
}