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,135 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { apiFetch } from '@/lib/api/client';
interface Delivery {
id: string;
eventType: string;
status: string;
responseStatus: number | null;
attempt: number;
deliveredAt: string | null;
createdAt: string;
}
interface Props {
webhookId: string;
}
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
success: 'default',
pending: 'secondary',
failed: 'destructive',
dead_letter: 'destructive',
};
export function WebhookDeliveryLog({ webhookId }: Props) {
const [deliveries, setDeliveries] = useState<Delivery[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
async function load(p: number) {
setLoading(true);
try {
const result = await apiFetch<{ data: Delivery[]; total: number }>(
`/api/v1/admin/webhooks/${webhookId}/deliveries?page=${p}&limit=25`,
);
setDeliveries(result.data);
setTotal(result.total);
} catch {
// ignore
} finally {
setLoading(false);
}
}
useEffect(() => {
void load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [webhookId, page]);
if (loading && deliveries.length === 0) {
return <p className="text-sm text-muted-foreground">Loading deliveries...</p>;
}
if (!loading && deliveries.length === 0) {
return <p className="text-sm text-muted-foreground">No deliveries yet.</p>;
}
const totalPages = Math.ceil(total / 25);
return (
<div className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Event</TableHead>
<TableHead>Status</TableHead>
<TableHead>HTTP</TableHead>
<TableHead>Attempt</TableHead>
<TableHead>Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deliveries.map((d) => (
<TableRow key={d.id}>
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
<TableCell>
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
{d.status}
</Badge>
</TableCell>
<TableCell className="text-sm">
{d.responseStatus ?? '—'}
</TableCell>
<TableCell className="text-sm">{d.attempt}</TableCell>
<TableCell className="text-xs text-muted-foreground">
{d.deliveredAt
? new Date(d.deliveredAt).toLocaleString()
: new Date(d.createdAt).toLocaleString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Page {page} of {totalPages} ({total} total)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { WEBHOOK_EVENTS, type WebhookEvent } from '@/lib/services/webhook-event-map';
// ─── Event Groups ─────────────────────────────────────────────────────────────
const EVENT_GROUPS: { label: string; events: WebhookEvent[] }[] = [
{
label: 'Clients',
events: ['client.created', 'client.updated', 'client.archived', 'client.merged'],
},
{
label: 'Interests',
events: ['interest.created', 'interest.stage_changed', 'interest.berth_linked'],
},
{
label: 'Berths',
events: ['berth.status_changed', 'berth.updated'],
},
{
label: 'Documents',
events: ['document.sent', 'document.signed', 'document.completed', 'document.expired'],
},
{
label: 'Expenses',
events: ['expense.created', 'expense.updated'],
},
{
label: 'Invoices',
events: ['invoice.created', 'invoice.sent', 'invoice.paid', 'invoice.overdue'],
},
{
label: 'Registrations',
events: ['registration.new'],
},
];
interface WebhookEventSelectorProps {
selected: WebhookEvent[];
onChange: (events: WebhookEvent[]) => void;
}
export function WebhookEventSelector({ selected, onChange }: WebhookEventSelectorProps) {
function toggle(event: WebhookEvent) {
if (selected.includes(event)) {
onChange(selected.filter((e) => e !== event));
} else {
onChange([...selected, event]);
}
}
function toggleGroup(events: WebhookEvent[]) {
const allSelected = events.every((e) => selected.includes(e));
if (allSelected) {
onChange(selected.filter((e) => !events.includes(e)));
} else {
const newEvents = [...selected];
for (const e of events) {
if (!newEvents.includes(e)) newEvents.push(e);
}
onChange(newEvents);
}
}
return (
<div className="space-y-4">
{EVENT_GROUPS.map((group) => {
const allChecked = group.events.every((e) => selected.includes(e));
const someChecked = group.events.some((e) => selected.includes(e));
return (
<div key={group.label}>
<div className="flex items-center gap-2 mb-2">
<Checkbox
id={`group-${group.label}`}
checked={allChecked}
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
onCheckedChange={() => toggleGroup(group.events)}
/>
<Label
htmlFor={`group-${group.label}`}
className="font-semibold text-sm cursor-pointer"
>
{group.label}
</Label>
</div>
<div className="ml-6 grid grid-cols-2 gap-1">
{group.events.map((event) => (
<div key={event} className="flex items-center gap-2">
<Checkbox
id={`event-${event}`}
checked={selected.includes(event)}
onCheckedChange={() => toggle(event)}
/>
<Label
htmlFor={`event-${event}`}
className="text-xs font-mono cursor-pointer"
>
{event}
</Label>
</div>
))}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { WebhookEventSelector } from './webhook-event-selector';
import { WebhookSecretDisplay } from './webhook-secret-display';
import type { WebhookEvent } from '@/lib/services/webhook-event-map';
interface WebhookFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
webhook?: {
id: string;
name: string;
url: string;
events: string[];
isActive: boolean;
secretMasked: string;
} | null;
onSuccess: () => void;
}
export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookFormProps) {
const isEdit = !!webhook;
const [name, setName] = useState(webhook?.name ?? '');
const [url, setUrl] = useState(webhook?.url ?? '');
const [events, setEvents] = useState<WebhookEvent[]>((webhook?.events ?? []) as WebhookEvent[]);
const [isActive, setIsActive] = useState(webhook?.isActive ?? true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/webhooks/${webhook.id}`, {
method: 'PATCH',
body: { name, url, events, isActive },
});
onSuccess();
onOpenChange(false);
} else {
const result = await apiFetch<{ data: { secret: string } }>('/api/v1/admin/webhooks', {
method: 'POST',
body: { name, url, events, isActive },
});
setCreatedSecret(result.data.secret);
onSuccess();
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
function handleClose() {
setName(webhook?.name ?? '');
setUrl(webhook?.url ?? '');
setEvents((webhook?.events ?? []) as WebhookEvent[]);
setIsActive(webhook?.isActive ?? true);
setError(null);
setCreatedSecret(null);
onOpenChange(false);
}
return (
<Sheet open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Webhook' : 'New Webhook'}</SheetTitle>
</SheetHeader>
{createdSecret ? (
<div className="mt-6 space-y-4">
<p className="text-sm">Webhook created successfully.</p>
<WebhookSecretDisplay plaintext={createdSecret} masked="" />
<Button onClick={handleClose} className="w-full">Done</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="webhook-name">Name</Label>
<Input
id="webhook-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Zapier Integration"
maxLength={200}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-url">URL</Label>
<Input
id="webhook-url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://hooks.example.com/webhook"
type="url"
required
/>
</div>
<div className="space-y-2">
<Label>Events</Label>
<WebhookEventSelector selected={events} onChange={setEvents} />
</div>
<div className="flex items-center gap-3">
<Switch
id="webhook-active"
checked={isActive}
onCheckedChange={setIsActive}
/>
<Label htmlFor="webhook-active">Active</Label>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<SheetFooter>
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim() || !url.trim() || events.length === 0}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Webhook'}
</Button>
</SheetFooter>
</form>
)}
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
interface WebhookSecretDisplayProps {
/** Plaintext secret (shown once on creation). If undefined, shows masked. */
plaintext?: string;
/** Masked preview (always shown on view). */
masked: string;
}
export function WebhookSecretDisplay({ plaintext, masked }: WebhookSecretDisplayProps) {
const [copied, setCopied] = useState(false);
async function copySecret() {
if (!plaintext) return;
await navigator.clipboard.writeText(plaintext);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (plaintext) {
return (
<div className="space-y-2">
<div className="rounded-md bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800">
<strong>Copy this secret now.</strong> It will not be shown again.
</div>
<div className="flex items-center gap-2">
<Input
readOnly
value={plaintext}
className="font-mono text-sm"
/>
<Button type="button" variant="outline" size="sm" onClick={copySecret}>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-2">
<Input
readOnly
value={masked}
className="font-mono text-sm text-muted-foreground"
/>
<span className="text-xs text-muted-foreground">Use &quot;Regenerate&quot; to get a new secret</span>
</div>
);
}