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:
343
src/components/admin/custom-fields/custom-field-form.tsx
Normal file
343
src/components/admin/custom-fields/custom-field-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
src/components/admin/custom-fields/custom-fields-manager.tsx
Normal file
224
src/components/admin/custom-fields/custom-fields-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
239
src/components/admin/document-templates/template-form.tsx
Normal file
239
src/components/admin/document-templates/template-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
256
src/components/admin/document-templates/template-list.tsx
Normal file
256
src/components/admin/document-templates/template-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/components/admin/document-templates/template-preview.tsx
Normal file
133
src/components/admin/document-templates/template-preview.tsx
Normal 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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
211
src/components/admin/queue-detail-table.tsx
Normal file
211
src/components/admin/queue-detail-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/admin/queue-overview.tsx
Normal file
75
src/components/admin/queue-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/admin/service-health-card.tsx
Normal file
52
src/components/admin/service-health-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/components/admin/system-monitoring-dashboard.tsx
Normal file
197
src/components/admin/system-monitoring-dashboard.tsx
Normal 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'} —{' '}
|
||||
{new Date(error.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
142
src/components/admin/tags/tag-form.tsx
Normal file
142
src/components/admin/tags/tag-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/components/admin/tags/tag-list.tsx
Normal file
169
src/components/admin/tags/tag-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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