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,343 @@
'use client';
import { useState, useEffect } from 'react';
import { Plus, X } from 'lucide-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 {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
// ─── Types ────────────────────────────────────────────────────────────────────
export interface CustomFieldDefinition {
id: string;
entityType: string;
fieldName: string;
fieldLabel: string;
fieldType: string;
selectOptions: string[] | null;
isRequired: boolean;
sortOrder: number;
}
interface CustomFieldFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
field?: CustomFieldDefinition | null;
onSuccess: () => void;
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
client: 'Clients',
interest: 'Interests',
berth: 'Berths',
};
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Yes / No',
select: 'Dropdown (Select)',
};
// ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldForm({
open,
onOpenChange,
field,
onSuccess,
}: CustomFieldFormProps) {
const isEdit = !!field;
// Form state
const [entityType, setEntityType] = useState(field?.entityType ?? 'client');
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
const [selectOptions, setSelectOptions] = useState<string[]>(
field?.selectOptions ?? [],
);
const [newOption, setNewOption] = useState('');
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setEntityType(field?.entityType ?? 'client');
setFieldName(field?.fieldName ?? '');
setFieldLabel(field?.fieldLabel ?? '');
setFieldType(field?.fieldType ?? 'text');
setSelectOptions(field?.selectOptions ?? []);
setNewOption('');
setIsRequired(field?.isRequired ?? false);
setSortOrder(field?.sortOrder ?? 0);
setError(null);
}
}, [open, field]);
// ── Select options management ──────────────────────────────────────────────
function addOption() {
const trimmed = newOption.trim();
if (!trimmed || selectOptions.includes(trimmed)) return;
setSelectOptions((prev) => [...prev, trimmed]);
setNewOption('');
}
function removeOption(opt: string) {
setSelectOptions((prev) => prev.filter((o) => o !== opt));
}
function handleOptionKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
addOption();
}
}
// ── Submit ─────────────────────────────────────────────────────────────────
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (fieldType === 'select' && selectOptions.length === 0) {
setError('Select fields must have at least one option.');
return;
}
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/admin/custom-fields/${field.id}`, {
method: 'PATCH',
body: {
fieldLabel,
selectOptions: fieldType === 'select' ? selectOptions : undefined,
isRequired,
sortOrder,
},
});
} else {
await apiFetch('/api/v1/admin/custom-fields', {
method: 'POST',
body: {
entityType,
fieldName,
fieldLabel,
fieldType,
selectOptions: fieldType === 'select' ? selectOptions : undefined,
isRequired,
sortOrder,
},
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-5 py-2">
{/* Entity Type — create only */}
<div className="space-y-1.5">
<Label htmlFor="cf-entity-type">Entity Type</Label>
{isEdit ? (
<p className="text-sm text-muted-foreground">
{ENTITY_TYPE_LABELS[entityType] ?? entityType}
</p>
) : (
<Select value={entityType} onValueChange={setEntityType}>
<SelectTrigger id="cf-entity-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ENTITY_TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Field Name — create only */}
<div className="space-y-1.5">
<Label htmlFor="cf-field-name">
Field Name
<span className="ml-1 text-xs text-muted-foreground">(snake_case)</span>
</Label>
{isEdit ? (
<p className="font-mono text-sm text-muted-foreground">{fieldName}</p>
) : (
<Input
id="cf-field-name"
value={fieldName}
onChange={(e) => setFieldName(e.target.value)}
placeholder="e.g. vessel_type"
pattern="^[a-z_][a-z0-9_]*$"
maxLength={50}
required
/>
)}
</div>
{/* Field Label */}
<div className="space-y-1.5">
<Label htmlFor="cf-field-label">Display Label</Label>
<Input
id="cf-field-label"
value={fieldLabel}
onChange={(e) => setFieldLabel(e.target.value)}
placeholder="e.g. Vessel Type"
maxLength={100}
required
/>
</div>
{/* Field Type — create only */}
<div className="space-y-1.5">
<Label htmlFor="cf-field-type">Field Type</Label>
{isEdit ? (
<div>
<p className="text-sm text-muted-foreground">
{FIELD_TYPE_LABELS[fieldType] ?? fieldType}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Type cannot be changed after creation.
</p>
</div>
) : (
<Select value={fieldType} onValueChange={setFieldType}>
<SelectTrigger id="cf-field-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FIELD_TYPE_LABELS).map(([val, label]) => (
<SelectItem key={val} value={val}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Select Options — visible when fieldType = 'select' */}
{fieldType === 'select' && (
<div className="space-y-2">
<Label>Options</Label>
<div className="flex gap-2">
<Input
value={newOption}
onChange={(e) => setNewOption(e.target.value)}
onKeyDown={handleOptionKeyDown}
placeholder="Add option..."
maxLength={100}
/>
<Button type="button" variant="outline" size="sm" onClick={addOption}>
<Plus className="h-4 w-4" />
</Button>
</div>
{selectOptions.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-1">
{selectOptions.map((opt) => (
<span
key={opt}
className="flex items-center gap-1 rounded-md border bg-muted px-2 py-0.5 text-sm"
>
{opt}
<button
type="button"
onClick={() => removeOption(opt)}
className="ml-0.5 text-muted-foreground hover:text-destructive"
aria-label={`Remove option ${opt}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
</div>
)}
{/* Is Required */}
<div className="flex items-center justify-between">
<Label htmlFor="cf-is-required">Required field</Label>
<Switch
id="cf-is-required"
checked={isRequired}
onCheckedChange={setIsRequired}
/>
</div>
{/* Sort Order */}
<div className="space-y-1.5">
<Label htmlFor="cf-sort-order">Sort Order</Label>
<Input
id="cf-sort-order"
type="number"
value={sortOrder}
onChange={(e) => setSortOrder(parseInt(e.target.value, 10) || 0)}
min={0}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Field'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { apiFetch } from '@/lib/api/client';
import {
CustomFieldForm,
type CustomFieldDefinition,
} from './custom-field-form';
// ─── Types ────────────────────────────────────────────────────────────────────
type EntityTab = 'client' | 'interest' | 'berth';
const TAB_LABELS: Record<EntityTab, string> = {
client: 'Clients',
interest: 'Interests',
berth: 'Berths',
};
const FIELD_TYPE_LABELS: Record<string, string> = {
text: 'Text',
number: 'Number',
date: 'Date',
boolean: 'Yes / No',
select: 'Dropdown',
};
// ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldsManager() {
const [activeTab, setActiveTab] = useState<EntityTab>('client');
const [fields, setFields] = useState<CustomFieldDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingField, setEditingField] = useState<CustomFieldDefinition | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchFields = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: CustomFieldDefinition[] }>(
'/api/v1/admin/custom-fields',
);
setFields(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchFields();
}, [fetchFields]);
const filteredFields = fields.filter((f) => f.entityType === activeTab);
function handleCreate() {
setEditingField(null);
setFormOpen(true);
}
function handleEdit(field: CustomFieldDefinition) {
setEditingField(field);
setFormOpen(true);
}
async function handleDelete(id: string) {
setDeletingId(id);
try {
await apiFetch(`/api/v1/admin/custom-fields/${id}`, { method: 'DELETE' });
await fetchFields();
} finally {
setDeletingId(null);
}
}
function getDeleteDescription(field: CustomFieldDefinition): string {
return `Are you sure you want to delete "${field.fieldLabel}" (${field.fieldName})? All stored values for this field will also be permanently deleted.`;
}
const columns: ColumnDef<CustomFieldDefinition, unknown>[] = [
{
accessorKey: 'fieldName',
header: 'Name',
cell: ({ row }) => (
<span className="font-mono text-sm">{row.original.fieldName}</span>
),
},
{
accessorKey: 'fieldLabel',
header: 'Label',
cell: ({ row }) => (
<span className="font-medium">{row.original.fieldLabel}</span>
),
},
{
accessorKey: 'fieldType',
header: 'Type',
cell: ({ row }) => (
<Badge variant="secondary">
{FIELD_TYPE_LABELS[row.original.fieldType] ?? row.original.fieldType}
</Badge>
),
},
{
accessorKey: 'isRequired',
header: 'Required',
cell: ({ row }) =>
row.original.isRequired ? (
<Badge variant="destructive" className="text-xs">
Required
</Badge>
) : (
<span className="text-xs text-muted-foreground">Optional</span>
),
},
{
accessorKey: 'sortOrder',
header: 'Order',
cell: ({ row }) => (
<span className="tabular-nums text-muted-foreground">
{row.original.sortOrder}
</span>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(row.original)}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={deletingId === row.original.id}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
}
title="Delete Custom Field"
description={getDeleteDescription(row.original)}
confirmLabel="Delete Field"
onConfirm={() => handleDelete(row.original.id)}
loading={deletingId === row.original.id}
/>
</div>
),
enableSorting: false,
size: 80,
},
];
return (
<div className="space-y-6">
<PageHeader
title="Custom Fields"
description="Define custom fields for clients and records"
actions={
<Button onClick={handleCreate}>
<Plus className="mr-1.5 h-4 w-4" />
New Field
</Button>
}
/>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as EntityTab)}>
<TabsList>
{(Object.keys(TAB_LABELS) as EntityTab[]).map((tab) => (
<TabsTrigger key={tab} value={tab}>
{TAB_LABELS[tab]}
</TabsTrigger>
))}
</TabsList>
{(Object.keys(TAB_LABELS) as EntityTab[]).map((tab) => (
<TabsContent key={tab} value={tab} className="mt-4">
<DataTable
columns={columns}
data={filteredFields}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="py-8 text-center">
<p className="text-muted-foreground">
No custom fields for {TAB_LABELS[tab]} yet.
</p>
<Button variant="link" onClick={handleCreate} className="mt-2">
Create the first field
</Button>
</div>
}
/>
</TabsContent>
))}
</Tabs>
<CustomFieldForm
open={formOpen}
onOpenChange={setFormOpen}
field={editingField}
onSuccess={fetchFields}
/>
</div>
);
}

View File

@@ -0,0 +1,239 @@
'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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
const DOCUMENT_TYPES = [
{ value: 'eoi', label: 'Expression of Interest' },
{ value: 'contract', label: 'Contract' },
{ value: 'nda', label: 'NDA' },
{ value: 'reservation_agreement', label: 'Reservation Agreement' },
{ value: 'letter', label: 'Letter' },
{ value: 'other', label: 'Other' },
] as const;
const EMPTY_DOC = JSON.stringify(
{
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '' }],
},
],
},
null,
2,
);
interface AdminTemplate {
id: string;
name: string;
templateType: string;
bodyHtml: string;
version: number;
isActive: boolean;
content: Record<string, unknown> | null;
}
interface TemplateFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
template?: AdminTemplate | null;
onSuccess: () => void;
}
export function TemplateForm({
open,
onOpenChange,
template,
onSuccess,
}: TemplateFormProps) {
const isEdit = !!template;
const [name, setName] = useState(template?.name ?? '');
const [type, setType] = useState(template?.templateType ?? 'other');
const [contentJson, setContentJson] = useState(
template?.content
? JSON.stringify(template.content, null, 2)
: EMPTY_DOC,
);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [jsonError, setJsonError] = useState<string | null>(null);
function validateJson(value: string): boolean {
try {
JSON.parse(value);
setJsonError(null);
return true;
} catch {
setJsonError('Invalid JSON — check syntax.');
return false;
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!validateJson(contentJson)) return;
setLoading(true);
try {
const content = JSON.parse(contentJson) as Record<string, unknown>;
if (isEdit) {
await apiFetch(`/api/v1/admin/templates/${template.id}`, {
method: 'PATCH',
body: { name, content },
});
} else {
await apiFetch('/api/v1/admin/templates', {
method: 'POST',
body: { name, type, content },
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
<SheetHeader>
<SheetTitle>
{isEdit ? 'Edit Template' : 'New Document Template'}
</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="template-name">Template Name</Label>
<Input
id="template-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. EOI Confirmation Letter"
required
/>
</div>
{/* Type — only on create */}
{!isEdit && (
<div className="space-y-2">
<Label htmlFor="template-type">Document Type</Label>
<Select value={type} onValueChange={setType}>
<SelectTrigger id="template-type">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
{DOCUMENT_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* TipTap JSON Content */}
<div className="space-y-2">
<Label htmlFor="template-content">
Document Content (TipTap JSON)
</Label>
<p className="text-xs text-muted-foreground">
Paste or edit TipTap JSON. Use{' '}
<code className="rounded bg-muted px-1 text-xs">
{'{{variable.key}}'}
</code>{' '}
tokens for dynamic content.
</p>
<textarea
id="template-content"
value={contentJson}
onChange={(e) => {
setContentJson(e.target.value);
validateJson(e.target.value);
}}
rows={18}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
spellCheck={false}
/>
{jsonError && (
<p className="text-xs text-destructive">{jsonError}</p>
)}
</div>
{/* Available Variables Reference */}
<details className="rounded-md border p-3">
<summary className="cursor-pointer text-sm font-medium">
Available Template Variables
</summary>
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
{TEMPLATE_VARIABLES.map((v) => (
<div key={v.key} className="text-xs">
<code className="rounded bg-muted px-1">
{`{{${v.key}}}`}
</code>{' '}
<span className="text-muted-foreground"> {v.label}</span>
</div>
))}
</div>
</details>
{error && (
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading || !!jsonError}>
{loading
? 'Saving…'
: isEdit
? 'Save Changes'
: 'Create Template'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,256 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Plus, Pencil, Trash2, History, FileText } from 'lucide-react';
import { type ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
import { TemplateForm } from './template-form';
import { TemplateVersionHistory } from './template-version-history';
import { TemplatePreview } from './template-preview';
interface AdminTemplate {
id: string;
name: string;
templateType: string;
bodyHtml: string;
version: number;
isActive: boolean;
createdAt: string;
updatedAt: string;
content: Record<string, unknown> | null;
}
const TYPE_LABELS: Record<string, string> = {
eoi: 'EOI',
contract: 'Contract',
nda: 'NDA',
reservation_agreement: 'Reservation Agreement',
letter: 'Letter',
other: 'Other',
welcome_letter: 'Welcome Letter',
handover_checklist: 'Handover Checklist',
acknowledgment: 'Acknowledgment',
correspondence: 'Correspondence',
custom: 'Custom',
};
export function TemplateList() {
const [templates, setTemplates] = useState<AdminTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AdminTemplate | null>(null);
const [historyTemplate, setHistoryTemplate] = useState<AdminTemplate | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const fetchTemplates = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: AdminTemplate[] }>(
'/api/v1/admin/templates',
);
setTemplates(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchTemplates();
}, [fetchTemplates]);
function handleNewTemplate() {
setEditingTemplate(null);
setFormOpen(true);
}
function handleEditTemplate(template: AdminTemplate) {
setEditingTemplate(template);
setFormOpen(true);
}
function handleViewHistory(template: AdminTemplate) {
setHistoryTemplate(template);
setHistoryOpen(true);
}
async function handleDeleteTemplate(id: string) {
await apiFetch(`/api/v1/admin/templates/${id}`, { method: 'DELETE' });
await fetchTemplates();
}
async function handleToggleActive(template: AdminTemplate) {
await apiFetch(`/api/v1/admin/templates/${template.id}`, {
method: 'PATCH',
body: { isActive: !template.isActive },
});
await fetchTemplates();
}
const columns: ColumnDef<AdminTemplate, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: 'templateType',
header: 'Type',
cell: ({ row }) => (
<Badge variant="secondary">
{TYPE_LABELS[row.original.templateType] ?? row.original.templateType}
</Badge>
),
},
{
accessorKey: 'version',
header: 'Version',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
v{row.original.version}
</span>
),
},
{
accessorKey: 'isActive',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.isActive ? 'default' : 'outline'}>
{row.original.isActive ? 'Active' : 'Inactive'}
</Badge>
),
},
{
accessorKey: 'updatedAt',
header: 'Last Updated',
cell: ({ row }) =>
new Date(row.original.updatedAt).toLocaleDateString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
}),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<TemplatePreview
content={row.original.content}
templateName={row.original.name}
/>
<Button
variant="ghost"
size="icon"
title="Version history"
onClick={() => handleViewHistory(row.original)}
>
<History className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Edit template"
onClick={() => handleEditTemplate(row.original)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title={row.original.isActive ? 'Deactivate' : 'Activate'}
onClick={() => handleToggleActive(row.original)}
>
<span className="text-xs">
{row.original.isActive ? 'Off' : 'On'}
</span>
</Button>
<ConfirmationDialog
trigger={
<Button variant="ghost" size="icon" title="Delete template">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
}
title="Delete Template"
description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`}
confirmLabel="Delete"
destructive
onConfirm={() => handleDeleteTemplate(row.original.id)}
/>
</div>
),
},
];
return (
<div className="space-y-6">
<PageHeader
title="Document Templates"
description="Manage reusable document templates with TipTap content and PDF generation"
actions={
<Button onClick={handleNewTemplate}>
<Plus className="mr-2 h-4 w-4" />
New Template
</Button>
}
/>
<DataTable
columns={columns}
data={templates}
isLoading={loading}
emptyState={
<div className="py-10 text-center text-muted-foreground">
No document templates yet. Create your first template to get started.
</div>
}
/>
<TemplateForm
open={formOpen}
onOpenChange={setFormOpen}
template={editingTemplate}
onSuccess={fetchTemplates}
/>
{/* Version History Sheet */}
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
<SheetHeader>
<SheetTitle>
Version History {historyTemplate?.name}
</SheetTitle>
</SheetHeader>
<div className="mt-6">
{historyTemplate && (
<TemplateVersionHistory
templateId={historyTemplate.id}
currentVersion={historyTemplate.version}
onRollback={() => {
void fetchTemplates();
setHistoryOpen(false);
}}
/>
)}
</div>
</SheetContent>
</Sheet>
</div>
);
}

View File

@@ -0,0 +1,133 @@
'use client';
import { useState } from 'react';
import { Eye, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
interface TemplatePreviewProps {
content: Record<string, unknown> | null;
templateName: string;
}
export function TemplatePreview({ content, templateName }: TemplatePreviewProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [pdfBase64, setPdfBase64] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Build sample data from TEMPLATE_VARIABLES examples
const sampleData = Object.fromEntries(
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
);
async function handlePreview() {
if (!content) {
setError('No content to preview.');
setOpen(true);
return;
}
setLoading(true);
setError(null);
setPdfBase64(null);
setOpen(true);
try {
const res = await apiFetch<{ data: { pdfBase64: string } }>(
'/api/v1/admin/templates/preview',
{
method: 'POST',
body: { content, sampleData },
},
);
setPdfBase64(res.data.pdfBase64);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Preview generation failed');
} finally {
setLoading(false);
}
}
function handleOpenInNewTab() {
if (!pdfBase64) return;
const blob = base64ToBlob(pdfBase64, 'application/pdf');
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
}
return (
<>
<Button variant="outline" size="sm" onClick={handlePreview}>
<Eye className="mr-1.5 h-3.5 w-3.5" />
Preview PDF
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<div className="flex items-center justify-between">
<DialogTitle>Preview {templateName}</DialogTitle>
{pdfBase64 && (
<Button
variant="ghost"
size="sm"
onClick={handleOpenInNewTab}
className="mr-6"
>
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
Open in new tab
</Button>
)}
</div>
</DialogHeader>
<div className="mt-2 min-h-[60vh]">
{loading && (
<div className="flex h-60 items-center justify-center text-muted-foreground">
<div className="text-center">
<div className="mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent mx-auto" />
<p className="text-sm">Generating preview</p>
</div>
</div>
)}
{error && !loading && (
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
)}
{pdfBase64 && !loading && (
<iframe
src={`data:application/pdf;base64,${pdfBase64}`}
className="h-[70vh] w-full rounded border"
title={`Preview: ${templateName}`}
/>
)}
</div>
<p className="text-xs text-muted-foreground">
Preview uses sample values for all template variables.
</p>
</DialogContent>
</Dialog>
</>
);
}
function base64ToBlob(base64: string, mimeType: string): Blob {
const byteCharacters = atob(base64);
const byteNumbers = new Uint8Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
return new Blob([byteNumbers], { type: mimeType });
}

View File

@@ -0,0 +1,144 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { RotateCcw, Clock } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
interface TemplateVersion {
version: number;
content: Record<string, unknown>;
changedBy: string | null;
changedAt: string;
auditLogId: string;
}
interface TemplateVersionHistoryProps {
templateId: string;
currentVersion: number;
onRollback: () => void;
}
export function TemplateVersionHistory({
templateId,
currentVersion,
onRollback,
}: TemplateVersionHistoryProps) {
const [versions, setVersions] = useState<TemplateVersion[]>([]);
const [loading, setLoading] = useState(true);
const [rollingBack, setRollingBack] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchVersions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await apiFetch<{ data: TemplateVersion[] }>(
`/api/v1/admin/templates/${templateId}/versions`,
);
setVersions(res.data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load versions');
} finally {
setLoading(false);
}
}, [templateId]);
useEffect(() => {
void fetchVersions();
}, [fetchVersions]);
async function handleRollback(version: number) {
if (!confirm(`Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`)) return;
setRollingBack(version);
setError(null);
try {
await apiFetch(`/api/v1/admin/templates/${templateId}/rollback`, {
method: 'POST',
body: { version },
});
onRollback();
await fetchVersions();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Rollback failed');
} finally {
setRollingBack(null);
}
}
if (loading) {
return (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading version history
</div>
);
}
if (versions.length === 0) {
return (
<div className="flex flex-col items-center gap-2 rounded-md border border-dashed p-6 text-center">
<Clock className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
No previous versions found. Versions are saved whenever you update the
template content.
</p>
</div>
);
}
return (
<div className="space-y-3">
{error && (
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</p>
)}
<p className="text-sm text-muted-foreground">
Current version: <strong>v{currentVersion}</strong>. Click Restore to
roll back to a previous version (creates a new version).
</p>
<div className="divide-y rounded-md border">
{versions.map((v) => (
<div
key={v.auditLogId}
className="flex items-center justify-between px-4 py-3"
>
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<Badge variant="outline">v{v.version}</Badge>
<span className="text-sm font-medium">
Version {v.version}
</span>
</div>
<p className="text-xs text-muted-foreground">
Saved{' '}
{new Date(v.changedAt).toLocaleString('en-GB', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
{v.changedBy ? ` by ${v.changedBy}` : ''}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleRollback(v.version)}
disabled={rollingBack === v.version}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{rollingBack === v.version ? 'Restoring…' : 'Restore'}
</Button>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Trash2, RotateCcw } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { QueueJobSummary, PaginatedQueueJobs } from '@/lib/services/system-monitoring.service';
type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
interface QueueDetailTableProps {
queueName: string;
}
const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
waiting: 'outline',
active: 'default',
completed: 'secondary',
failed: 'destructive',
delayed: 'outline',
};
function formatDate(ts: number | undefined): string {
if (!ts) return '—';
return new Date(ts).toLocaleString();
}
function truncateId(id: string): string {
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
}
function truncateReason(reason: string | undefined): string {
if (!reason) return '—';
return reason.length > 80 ? `${reason.slice(0, 80)}` : reason;
}
export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
const queryClient = useQueryClient();
const [status, setStatus] = useState<JobStatus>('failed');
const [page, setPage] = useState(1);
const limit = 20;
const { data, isLoading } = useQuery({
queryKey: ['queue', 'jobs', queueName, status, page],
queryFn: () =>
apiFetch<{ data: PaginatedQueueJobs }>(
`/api/v1/admin/queues/${queueName}?status=${status}&page=${page}&limit=${limit}`,
).then((r) => r.data),
staleTime: 10_000,
});
const retryMutation = useMutation({
mutationFn: (jobId: string) =>
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}/retry`, { method: 'POST' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
},
});
const deleteMutation = useMutation({
mutationFn: (jobId: string) =>
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}`, { method: 'DELETE' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
},
});
const jobs: QueueJobSummary[] = data?.jobs ?? [];
const total = data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
function handleStatusChange(value: string) {
setStatus(value as JobStatus);
setPage(1);
}
return (
<div className="space-y-4">
<Tabs value={status} onValueChange={handleStatusChange}>
<TabsList>
{(['waiting', 'active', 'completed', 'failed', 'delayed'] as JobStatus[]).map((s) => (
<TabsTrigger key={s} value={s} className="capitalize">
{s}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Processed</TableHead>
<TableHead className="max-w-[240px]">Failed Reason</TableHead>
<TableHead className="w-[100px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Loading jobs...
</TableCell>
</TableRow>
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
No {status} jobs
</TableCell>
</TableRow>
) : (
jobs.map((job) => (
<TableRow key={job.id}>
<TableCell className="font-mono text-xs" title={job.id}>
{truncateId(job.id)}
</TableCell>
<TableCell className="text-sm">{job.name}</TableCell>
<TableCell>
<Badge variant={statusVariant[status]}>{status}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.timestamp)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{formatDate(job.processedOn)}
</TableCell>
<TableCell
className="text-xs text-muted-foreground max-w-[240px] truncate"
title={job.failedReason}
>
{truncateReason(job.failedReason)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{status === 'failed' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title="Retry job"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(job.id)}
>
<RotateCcw className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
title="Delete job"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(job.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} total jobs page {page} of {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,75 @@
'use client';
import { useParams, useRouter } from 'next/navigation';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { QueueStatus } from '@/lib/services/system-monitoring.service';
interface QueueOverviewProps {
queues: QueueStatus[];
}
interface StatPillProps {
label: string;
value: number;
highlight?: boolean;
}
function StatPill({ label, value, highlight }: StatPillProps) {
return (
<div className="flex flex-col items-center">
<span
className={cn(
'text-lg font-bold leading-none',
highlight && value > 0 ? 'text-destructive' : 'text-foreground',
)}
>
{value}
</span>
<span className="text-[10px] text-muted-foreground mt-0.5 uppercase tracking-wide">
{label}
</span>
</div>
);
}
export function QueueOverview({ queues }: QueueOverviewProps) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
function handleQueueClick(queueName: string) {
router.push(`/${params.portSlug}/admin/monitoring/${queueName}` as any);
}
return (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
{queues.map((queue) => (
<Card
key={queue.name}
className="cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => handleQueueClick(queue.name)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && handleQueueClick(queue.name)}
>
<CardHeader className="p-3 pb-0">
<CardTitle className="text-xs font-semibold capitalize truncate">{queue.name}</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-2">
<div className="grid grid-cols-2 gap-x-2 gap-y-1">
<StatPill label="wait" value={queue.waiting} />
<StatPill label="active" value={queue.active} />
<StatPill label="done" value={queue.completed} />
<StatPill label="failed" value={queue.failed} highlight />
</div>
{queue.delayed > 0 && (
<p className="text-[10px] text-muted-foreground text-center mt-1">
{queue.delayed} delayed
</p>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card';
import type { ServiceStatus } from '@/lib/services/system-monitoring.service';
interface ServiceHealthCardProps {
service: ServiceStatus;
}
const statusConfig = {
healthy: {
dot: 'bg-green-500',
label: 'Healthy',
labelClass: 'text-green-700 dark:text-green-400',
},
degraded: {
dot: 'bg-yellow-500',
label: 'Degraded',
labelClass: 'text-yellow-700 dark:text-yellow-400',
},
down: {
dot: 'bg-red-500',
label: 'Down',
labelClass: 'text-red-700 dark:text-red-400',
},
} as const;
export function ServiceHealthCard({ service }: ServiceHealthCardProps) {
const config = statusConfig[service.status];
return (
<Card className="min-w-[160px]">
<CardContent className="p-4">
<div className="flex items-center gap-2 mb-2">
<span
className={cn('h-2.5 w-2.5 rounded-full flex-shrink-0', config.dot)}
aria-hidden="true"
/>
<span className="font-medium text-sm truncate">{service.name}</span>
</div>
<p className={cn('text-xs font-semibold', config.labelClass)}>{config.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{service.responseTimeMs}ms</p>
{service.details && (
<p className="text-xs text-muted-foreground mt-1 truncate" title={service.details}>
{service.details}
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Activity, Wifi, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ServiceHealthCard } from './service-health-card';
import { QueueOverview } from './queue-overview';
import type {
HealthStatus,
QueueStatus,
ConnectionStatus,
RecentError,
} from '@/lib/services/system-monitoring.service';
export function SystemMonitoringDashboard() {
const { data: healthData } = useQuery({
queryKey: ['system', 'health'],
queryFn: () =>
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
staleTime: 30_000,
refetchInterval: 30_000,
});
const { data: queuesData } = useQuery({
queryKey: ['system', 'queues'],
queryFn: () =>
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
staleTime: 10_000,
refetchInterval: 10_000,
});
const { data: connectionsData } = useQuery({
queryKey: ['system', 'connections'],
queryFn: () =>
apiFetch<{ data: ConnectionStatus }>('/api/v1/admin/connections').then((r) => r.data),
staleTime: 30_000,
refetchInterval: 30_000,
});
const queues: QueueStatus[] = queuesData ?? [];
const health: HealthStatus | undefined = healthData;
const connections = connectionsData?.totalConnections ?? 0;
const totalFailed = queues.reduce((sum, q) => sum + q.failed, 0);
return (
<div className="space-y-8">
{/* Page header */}
<div>
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1>
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p>
</div>
{/* Service health */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Service Health
</h2>
{health ? (
<>
<div className="flex items-center gap-2 mb-3">
{health.overall === 'healthy' ? (
<CheckCircle2 className="h-4 w-4 text-green-500" />
) : (
<AlertTriangle className="h-4 w-4 text-yellow-500" />
)}
<span className="text-sm text-muted-foreground">
All services checked at {new Date(health.checkedAt).toLocaleTimeString()}
</span>
</div>
<div className="flex flex-wrap gap-3">
{health.services.map((service) => (
<ServiceHealthCard key={service.name} service={service} />
))}
</div>
</>
) : (
<div className="flex gap-3">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
/>
))}
</div>
)}
</section>
{/* Stats row */}
<section className="grid grid-cols-2 sm:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
<Wifi className="h-3.5 w-3.5" />
Active Connections
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">{connections}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" />
Total Failed Jobs
</CardTitle>
</CardHeader>
<CardContent>
<p className={`text-3xl font-bold ${totalFailed > 0 ? 'text-destructive' : ''}`}>
{totalFailed}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
<Activity className="h-3.5 w-3.5" />
Active Jobs
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{queues.reduce((sum, q) => sum + q.active, 0)}
</p>
</CardContent>
</Card>
</section>
{/* Queue overview */}
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Queue Overview
</h2>
{queues.length > 0 ? (
<QueueOverview queues={queues} />
) : (
<div className="grid grid-cols-5 gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-[110px] rounded-xl border bg-card animate-pulse"
/>
))}
</div>
)}
</section>
{/* Recent errors */}
<RecentErrorsPanel />
</div>
);
}
function RecentErrorsPanel() {
const { data: errorsData } = useQuery({
queryKey: ['system', 'errors'],
queryFn: () =>
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
staleTime: 30_000,
refetchInterval: 30_000,
});
const errors: RecentError[] = errorsData ?? [];
return (
<section className="space-y-3">
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Recent Errors
</h2>
{errors.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent errors.</p>
) : (
<div className="space-y-2">
{errors.map((error) => (
<div
key={error.id}
className="flex items-start justify-between gap-4 rounded-lg border px-4 py-3 text-sm"
>
<div className="space-y-0.5 min-w-0">
<p className="font-medium truncate">{error.message}</p>
<p className="text-xs text-muted-foreground">
{error.source === 'queue' ? 'Queue' : 'Audit'} &mdash;{' '}
{new Date(error.timestamp).toLocaleString()}
</p>
</div>
</div>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,142 @@
'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 {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import { apiFetch } from '@/lib/api/client';
interface TagFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tag?: { id: string; name: string; color: string } | null;
onSuccess: () => void;
}
const PRESET_COLORS = [
'#6B7280', '#EF4444', '#F97316', '#EAB308',
'#22C55E', '#14B8A6', '#3B82F6', '#8B5CF6',
'#EC4899', '#F43F5E',
];
export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
const [name, setName] = useState(tag?.name ?? '');
const [color, setColor] = useState(tag?.color ?? '#6B7280');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isEdit = !!tag;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
if (isEdit) {
await apiFetch(`/api/v1/tags/${tag.id}`, {
method: 'PATCH',
body: { name, color },
});
} else {
await apiFetch('/api/v1/tags', {
method: 'POST',
body: { name, color },
});
}
onSuccess();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Something went wrong';
setError(message);
} finally {
setLoading(false);
}
}
function handleOpenChange(open: boolean) {
if (!open) {
setName(tag?.name ?? '');
setColor(tag?.color ?? '#6B7280');
setError(null);
}
onOpenChange(open);
}
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent>
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Tag' : 'New Tag'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
<div className="space-y-2">
<Label htmlFor="tag-name">Name</Label>
<Input
id="tag-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. VIP Client"
maxLength={50}
required
/>
</div>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
className="h-7 w-7 rounded-full ring-2 ring-offset-2 transition-all"
style={{
backgroundColor: c,
outline: color === c ? `2px solid ${c}` : '2px solid transparent',
outlineOffset: '2px',
}}
onClick={() => setColor(c)}
aria-label={`Select color ${c}`}
/>
))}
</div>
<div className="flex items-center gap-2 mt-2">
<div
className="h-7 w-7 rounded-full border"
style={{ backgroundColor: color }}
/>
<Input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#6B7280"
className="w-28 font-mono text-sm"
maxLength={7}
/>
</div>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<SheetFooter>
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading || !name.trim()}>
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Tag'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,169 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus } from 'lucide-react';
import { DataTable } from '@/components/shared/data-table';
import { PageHeader } from '@/components/shared/page-header';
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
import { TagForm } from './tag-form';
interface Tag {
id: string;
name: string;
color: string;
createdAt: string;
}
export function TagList() {
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchTags = useCallback(async () => {
setLoading(true);
try {
const res = await apiFetch<{ data: Tag[] }>('/api/v1/tags');
setTags(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void fetchTags();
}, [fetchTags]);
function handleNewTag() {
setEditingTag(null);
setFormOpen(true);
}
function handleEditTag(tag: Tag) {
setEditingTag(tag);
setFormOpen(true);
}
async function handleDeleteTag(id: string) {
setDeletingId(id);
try {
await apiFetch(`/api/v1/tags/${id}`, { method: 'DELETE' });
await fetchTags();
} finally {
setDeletingId(null);
}
}
const columns: ColumnDef<Tag, unknown>[] = [
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span
className="inline-block h-3 w-3 rounded-full"
style={{ backgroundColor: row.original.color }}
/>
<span className="font-medium">{row.original.name}</span>
</div>
),
},
{
accessorKey: 'color',
header: 'Color',
cell: ({ row }) => (
<Badge
variant="outline"
className="font-mono text-xs"
style={{ borderColor: row.original.color, color: row.original.color }}
>
{row.original.color}
</Badge>
),
},
{
accessorKey: 'createdAt',
header: 'Created',
cell: ({ row }) =>
new Date(row.original.createdAt).toLocaleDateString(),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEditTag(row.original)}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit</span>
</Button>
<ConfirmationDialog
trigger={
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
}
title="Delete Tag"
description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`}
confirmLabel="Delete"
onConfirm={() => handleDeleteTag(row.original.id)}
loading={deletingId === row.original.id}
/>
</div>
),
enableSorting: false,
size: 80,
},
];
return (
<div>
<PageHeader
title="Tag Management"
description="Manage tags used across clients and records"
actions={
<Button onClick={handleNewTag}>
<Plus className="mr-1.5 h-4 w-4" />
New Tag
</Button>
}
/>
<DataTable
columns={columns}
data={tags}
isLoading={loading}
getRowId={(row) => row.id}
emptyState={
<div className="text-center py-8">
<p className="text-muted-foreground">No tags yet.</p>
<Button variant="link" onClick={handleNewTag} className="mt-2">
Create your first tag
</Button>
</div>
}
/>
<TagForm
open={formOpen}
onOpenChange={setFormOpen}
tag={editingTag}
onSuccess={fetchTags}
/>
</div>
);
}

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>
);
}