Initial commit: Port Nimara CRM (Layers 0-4)
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:
135
src/components/admin/webhooks/webhook-delivery-log.tsx
Normal file
135
src/components/admin/webhooks/webhook-delivery-log.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/admin/webhooks/webhook-event-selector.tsx
Normal file
110
src/components/admin/webhooks/webhook-event-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/components/admin/webhooks/webhook-form.tsx
Normal file
152
src/components/admin/webhooks/webhook-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/admin/webhooks/webhook-secret-display.tsx
Normal file
54
src/components/admin/webhooks/webhook-secret-display.tsx
Normal 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 "Regenerate" to get a new secret</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user