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

View File

@@ -0,0 +1,162 @@
'use client';
import { type ColumnDef } from '@tanstack/react-table';
import { MoreHorizontal, Pencil, Activity } from 'lucide-react';
import { useRouter, useParams } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TagBadge } from '@/components/shared/tag-badge';
export type BerthRow = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
lengthM: string | null;
widthM: string | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tags: Array<{ id: string; name: string; color: string }>;
};
function StatusBadge({ status }: { status: string }) {
const variants: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-200',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-200',
sold: 'bg-red-100 text-red-800 border-red-200',
};
const labels: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
return (
<span
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium ${variants[status] ?? 'bg-muted text-muted-foreground'}`}
>
{labels[status] ?? status}
</span>
);
}
function ActionsCell({ row }: { row: { original: BerthRow } }) {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const berth = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${params.portSlug}/berths/${berth.id}`);
}}
>
<Activity className="mr-2 h-4 w-4" />
View details
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
router.push(`/${params.portSlug}/berths/${berth.id}?edit=true`);
}}
>
<Pencil className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export const berthColumns: ColumnDef<BerthRow, unknown>[] = [
{
accessorKey: 'mooringNumber',
header: 'Mooring #',
cell: ({ row }) => (
<span className="font-medium">{row.original.mooringNumber}</span>
),
},
{
accessorKey: 'area',
header: 'Area',
cell: ({ row }) => row.original.area ?? '—',
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: 'dimensions',
header: 'Dimensions',
enableSorting: false,
cell: ({ row }) => {
const { lengthM, widthM } = row.original;
if (!lengthM && !widthM) return '—';
return `${lengthM ?? '?'}m × ${widthM ?? '?'}m`;
},
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) => {
const { price, priceCurrency } = row.original;
if (!price) return '—';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: priceCurrency || 'USD',
maximumFractionDigits: 0,
}).format(Number(price));
},
},
{
accessorKey: 'tenureType',
header: 'Tenure',
cell: ({ row }) =>
row.original.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term',
},
{
id: 'tags',
header: 'Tags',
enableSorting: false,
cell: ({ row }) => {
const { tags } = row.original;
if (!tags || tags.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{tags.slice(0, 3).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{tags.length > 3 && (
<span className="text-xs text-muted-foreground">+{tags.length - 3}</span>
)}
</div>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => <ActionsCell row={row} />,
},
];

View File

@@ -0,0 +1,215 @@
'use client';
import { useState } from 'react';
import { Pencil, RefreshCw } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { PermissionGate } from '@/components/shared/permission-gate';
import { BerthForm } from './berth-form';
import { apiFetch } from '@/lib/api/client';
import { updateBerthStatusSchema, type UpdateBerthStatusInput } from '@/lib/validators/berths';
import { BERTH_STATUSES } from '@/lib/constants';
type BerthDetailData = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
portId: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>;
};
interface BerthDetailHeaderProps {
berth: BerthDetailData;
}
const STATUS_COLORS: Record<string, string> = {
available: 'bg-green-100 text-green-800 border-green-300',
under_offer: 'bg-yellow-100 text-yellow-800 border-yellow-300',
sold: 'bg-red-100 text-red-800 border-red-300',
};
const STATUS_LABELS: Record<string, string> = {
available: 'Available',
under_offer: 'Under Offer',
sold: 'Sold',
};
function StatusChangeDialog({
berthId,
currentStatus,
open,
onOpenChange,
}: {
berthId: string;
currentStatus: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { isSubmitting },
} = useForm<UpdateBerthStatusInput>({
resolver: zodResolver(updateBerthStatusSchema),
defaultValues: { status: currentStatus as typeof BERTH_STATUSES[number], reason: '' },
});
const status = watch('status');
async function onSubmit(data: UpdateBerthStatusInput) {
try {
await apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: data,
});
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berthId] });
toast.success('Status updated');
reset();
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update status';
toast.error(message);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Status</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label>New Status</Label>
<Select
value={status}
onValueChange={(v) => setValue('status', v as typeof BERTH_STATUSES[number])}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{BERTH_STATUSES.map((s) => (
<SelectItem key={s} value={s}>
{STATUS_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason *</Label>
<Textarea
{...register('reason')}
placeholder="Reason for status change..."
rows={3}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Update Status'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function BerthDetailHeader({ berth }: BerthDetailHeaderProps) {
const [editOpen, setEditOpen] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
return (
<>
<div className="space-y-3">
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-bold text-foreground">
Berth {berth.mooringNumber}
</h1>
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-sm font-medium ${STATUS_COLORS[berth.status] ?? 'bg-muted text-muted-foreground border-muted'}`}
>
{STATUS_LABELS[berth.status] ?? berth.status}
</span>
</div>
{berth.area && (
<p className="text-muted-foreground mt-1">{berth.area}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<PermissionGate resource="berths" action="edit">
<Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>
<RefreshCw className="mr-1.5 h-4 w-4" />
Change Status
</Button>
<Button size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-4 w-4" />
Edit
</Button>
</PermissionGate>
</div>
</div>
</div>
<BerthForm berth={berth} open={editOpen} onOpenChange={setEditOpen} />
<StatusChangeDialog
berthId={berth.id}
currentStatus={berth.status}
open={statusOpen}
onOpenChange={setStatusOpen}
/>
</>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DetailLayout } from '@/components/shared/detail-layout';
import { apiFetch } from '@/lib/api/client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { BerthDetailHeader } from './berth-detail-header';
import { buildBerthTabs } from './berth-tabs';
interface BerthDetailProps {
berthId: string;
}
export function BerthDetail({ berthId }: BerthDetailProps) {
const { data, isLoading } = useQuery({
queryKey: ['berth', berthId],
queryFn: () =>
apiFetch<{ data: Record<string, unknown> }>(`/api/v1/berths/${berthId}`).then((r) => r.data),
});
useRealtimeInvalidation({
'berth:updated': [['berth', berthId]],
'berth:statusChanged': [['berth', berthId]],
});
const berth = data as any;
return (
<DetailLayout
isLoading={isLoading}
header={berth ? <BerthDetailHeader berth={berth} /> : null}
tabs={berth ? buildBerthTabs(berth) : []}
defaultTab="overview"
/>
);
}

View File

@@ -0,0 +1,41 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
import { BERTH_STATUSES } from '@/lib/constants';
export const berthFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search mooring number or area...',
},
{
key: 'status',
label: 'Status',
type: 'multi-select',
options: BERTH_STATUSES.map((s) => ({
value: s,
label: s === 'available' ? 'Available' : s === 'under_offer' ? 'Under Offer' : 'Sold',
})),
},
{
key: 'area',
label: 'Area',
type: 'text',
placeholder: 'Filter by area...',
},
{
key: 'tenureType',
label: 'Tenure Type',
type: 'select',
options: [
{ value: 'permanent', label: 'Permanent' },
{ value: 'fixed_term', label: 'Fixed Term' },
],
},
{
key: 'tagIds',
label: 'Tags',
type: 'multi-select',
options: [], // populated dynamically via TagPicker in the list component
},
];

View File

@@ -0,0 +1,305 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
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 { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { updateBerthSchema, type UpdateBerthInput } from '@/lib/validators/berths';
interface BerthFormProps {
berth: {
id: string;
mooringNumber: string;
area: string | null;
status: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
price: string | null;
priceCurrency: string;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
access: string | null;
berthApproved: boolean | null;
tags: Array<{ id: string; name: string; color: string }>;
};
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
const queryClient = useQueryClient();
const [tagIds, setTagIds] = useState<string[]>(berth.tags.map((t) => t.id));
const {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
reset,
} = useForm<UpdateBerthInput>({
resolver: zodResolver(updateBerthSchema),
defaultValues: {
area: berth.area ?? undefined,
lengthFt: berth.lengthFt ? Number(berth.lengthFt) : undefined,
lengthM: berth.lengthM ? Number(berth.lengthM) : undefined,
widthFt: berth.widthFt ? Number(berth.widthFt) : undefined,
widthM: berth.widthM ? Number(berth.widthM) : undefined,
draftFt: berth.draftFt ? Number(berth.draftFt) : undefined,
draftM: berth.draftM ? Number(berth.draftM) : undefined,
widthIsMinimum: berth.widthIsMinimum ?? false,
price: berth.price ? Number(berth.price) : undefined,
priceCurrency: berth.priceCurrency,
tenureType: berth.tenureType as 'permanent' | 'fixed_term',
tenureYears: berth.tenureYears ?? undefined,
tenureStartDate: berth.tenureStartDate ?? undefined,
tenureEndDate: berth.tenureEndDate ?? undefined,
powerCapacity: berth.powerCapacity ?? undefined,
voltage: berth.voltage ?? undefined,
mooringType: berth.mooringType ?? undefined,
access: berth.access ?? undefined,
berthApproved: berth.berthApproved ?? false,
},
});
const tagMutation = useMutation({
mutationFn: (ids: string[]) =>
apiFetch(`/api/v1/berths/${berth.id}/tags`, {
method: 'PUT',
body: { tagIds: ids },
}),
});
async function onSubmit(data: UpdateBerthInput) {
try {
await apiFetch(`/api/v1/berths/${berth.id}`, {
method: 'PATCH',
body: data,
});
await tagMutation.mutateAsync(tagIds);
queryClient.invalidateQueries({ queryKey: ['berths'] });
queryClient.invalidateQueries({ queryKey: ['berth', berth.id] });
toast.success('Berth updated');
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update berth';
toast.error(message);
}
}
const tenureType = watch('tenureType');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[480px] sm:w-[540px] overflow-y-auto">
<SheetHeader>
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 py-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Basic Info
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="area">Area</Label>
<Input id="area" {...register('area')} placeholder="e.g. Marina A" />
</div>
<div className="space-y-2">
<Label htmlFor="mooringType">Mooring Type</Label>
<Input id="mooringType" {...register('mooringType')} />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="access">Access</Label>
<Input id="access" {...register('access')} />
</div>
<div className="flex items-center gap-2">
<Switch
id="berthApproved"
checked={watch('berthApproved') ?? false}
onCheckedChange={(v) => setValue('berthApproved', v)}
/>
<Label htmlFor="berthApproved">Berth Approved</Label>
</div>
</div>
<Separator />
{/* Dimensions */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Dimensions
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Length (ft)</Label>
<Input type="number" step="0.1" {...register('lengthFt')} />
</div>
<div className="space-y-2">
<Label>Length (m)</Label>
<Input type="number" step="0.1" {...register('lengthM')} />
</div>
<div className="space-y-2">
<Label>Width (ft)</Label>
<Input type="number" step="0.1" {...register('widthFt')} />
</div>
<div className="space-y-2">
<Label>Width (m)</Label>
<Input type="number" step="0.1" {...register('widthM')} />
</div>
<div className="space-y-2">
<Label>Draft (ft)</Label>
<Input type="number" step="0.1" {...register('draftFt')} />
</div>
<div className="space-y-2">
<Label>Draft (m)</Label>
<Input type="number" step="0.1" {...register('draftM')} />
</div>
</div>
<div className="flex items-center gap-2">
<Switch
id="widthIsMinimum"
checked={watch('widthIsMinimum') ?? false}
onCheckedChange={(v) => setValue('widthIsMinimum', v)}
/>
<Label htmlFor="widthIsMinimum">Width is minimum</Label>
</div>
</div>
<Separator />
{/* Price */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Price
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Price</Label>
<Input type="number" step="0.01" {...register('price')} />
</div>
<div className="space-y-2">
<Label>Currency</Label>
<Input {...register('priceCurrency')} placeholder="USD" maxLength={3} />
</div>
</div>
</div>
<Separator />
{/* Tenure */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Tenure
</h3>
<div className="space-y-2">
<Label>Tenure Type</Label>
<Select
value={tenureType}
onValueChange={(v) => setValue('tenureType', v as 'permanent' | 'fixed_term')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="permanent">Permanent</SelectItem>
<SelectItem value="fixed_term">Fixed Term</SelectItem>
</SelectContent>
</Select>
</div>
{tenureType === 'fixed_term' && (
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Years</Label>
<Input type="number" {...register('tenureYears')} />
</div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" {...register('tenureStartDate')} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" {...register('tenureEndDate')} />
</div>
</div>
)}
</div>
<Separator />
{/* Infrastructure */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Infrastructure
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Power Capacity</Label>
<Input {...register('powerCapacity')} />
</div>
<div className="space-y-2">
<Label>Voltage</Label>
<Input {...register('voltage')} />
</div>
</div>
</div>
<Separator />
{/* Tags */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
Tags
</h3>
<TagPicker selectedIds={tagIds} onChange={setTagIds} />
</div>
<SheetFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useRouter, useParams } from 'next/navigation';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { PageHeader } from '@/components/shared/page-header';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { EmptyState } from '@/components/shared/empty-state';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { berthColumns, type BerthRow } from './berth-columns';
import { berthFilterDefinitions } from './berth-filters';
import { Anchor } from 'lucide-react';
export function BerthList() {
const router = useRouter();
const params = useParams<{ portSlug: string }>();
const {
data,
pagination,
isLoading,
sort,
setSort,
filters,
setFilter,
clearFilters,
setPage,
} = usePaginatedQuery<BerthRow>({
queryKey: ['berths'],
endpoint: '/api/v1/berths',
filterDefinitions: berthFilterDefinitions,
});
useRealtimeInvalidation({
'berth:updated': [['berths']],
'berth:statusChanged': [['berths']],
});
return (
<div className="space-y-6">
<PageHeader
title="Berths"
description="View and manage berth allocations"
// No "New" button — berths are import-only
/>
<div className="flex items-center gap-2 flex-wrap">
<FilterBar
filters={berthFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<div className="ml-auto">
<SavedViewsDropdown
entityType="berths"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, value]) => setFilter(key, value));
}}
/>
</div>
</div>
<DataTable<BerthRow>
columns={berthColumns}
data={data}
isLoading={isLoading}
pagination={{
page: pagination.page,
pageSize: pagination.pageSize,
total: pagination.total,
totalPages: pagination.totalPages,
}}
onPaginationChange={(page) => setPage(page)}
sort={sort}
onSortChange={setSort}
getRowId={(row) => row.id}
onRowClick={(row) => router.push(`/${params.portSlug}/berths/${row.id}`)}
emptyState={
<EmptyState
icon={Anchor}
title="No berths found"
description="Berths are imported from external sources. Adjust your filters to find what you're looking for."
/>
}
/>
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { ArrowRight, Loader2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
interface BerthStatusSuggestionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
berthId: string;
currentStatus: string;
suggestedStatus: string;
reason: string;
onApplied: () => void;
}
export function BerthStatusSuggestionDialog({
open,
onOpenChange,
berthId,
currentStatus,
suggestedStatus,
reason,
onApplied,
}: BerthStatusSuggestionDialogProps) {
const applyMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/berths/${berthId}/status`, {
method: 'PATCH',
body: JSON.stringify({ status: suggestedStatus, reason }),
}),
onSuccess: () => {
onApplied();
onOpenChange(false);
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Suggested Status Change</DialogTitle>
<DialogDescription>
Based on recent activity, a berth status update is recommended.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center gap-4 py-4">
<Badge variant="outline" className="text-base px-4 py-1.5">
{currentStatus.replace(/_/g, ' ')}
</Badge>
<ArrowRight className="h-5 w-5 text-muted-foreground" />
<Badge variant="default" className="text-base px-4 py-1.5">
{suggestedStatus.replace(/_/g, ' ')}
</Badge>
</div>
{reason && (
<p className="text-sm text-muted-foreground text-center px-4">{reason}</p>
)}
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
<Button
onClick={() => applyMutation.mutate()}
disabled={applyMutation.isPending}
>
{applyMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Apply Change
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,200 @@
'use client';
import { type DetailTab } from '@/components/shared/detail-layout';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
type BerthData = {
id: string;
mooringNumber: string;
area: string | null;
status: string;
lengthFt: string | null;
lengthM: string | null;
widthFt: string | null;
widthM: string | null;
draftFt: string | null;
draftM: string | null;
widthIsMinimum: boolean | null;
nominalBoatSize: string | null;
nominalBoatSizeM: string | null;
waterDepth: string | null;
waterDepthM: string | null;
waterDepthIsMinimum: boolean | null;
sidePontoon: string | null;
powerCapacity: string | null;
voltage: string | null;
mooringType: string | null;
cleatType: string | null;
cleatCapacity: string | null;
bollardType: string | null;
bollardCapacity: string | null;
access: string | null;
price: string | null;
priceCurrency: string;
bowFacing: string | null;
berthApproved: boolean | null;
tenureType: string;
tenureYears: number | null;
tenureStartDate: string | null;
tenureEndDate: string | null;
statusLastChangedReason: string | null;
statusLastModified: string | null;
tags: Array<{ id: string; name: string; color: string }>;
};
function SpecRow({ label, value }: { label: string; value: React.ReactNode }) {
if (!value && value !== 0 && value !== false) return null;
return (
<div className="flex justify-between py-2 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right max-w-[60%]">{value}</span>
</div>
);
}
function OverviewTab({ berth }: { berth: BerthData }) {
const formatDim = (ft: string | null, m: string | null) => {
const parts = [];
if (ft) parts.push(`${ft} ft`);
if (m) parts.push(`${m} m`);
return parts.length > 0 ? parts.join(' / ') : null;
};
const price = berth.price
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: berth.priceCurrency || 'USD',
maximumFractionDigits: 0,
}).format(Number(berth.price))
: null;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Specifications */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Specifications</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Length" value={formatDim(berth.lengthFt, berth.lengthM)} />
<SpecRow
label="Width"
value={
formatDim(berth.widthFt, berth.widthM)
? `${formatDim(berth.widthFt, berth.widthM)}${berth.widthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Draft" value={formatDim(berth.draftFt, berth.draftM)} />
<SpecRow label="Nominal Boat Size" value={berth.nominalBoatSize || berth.nominalBoatSizeM} />
<SpecRow
label="Water Depth"
value={
berth.waterDepth || berth.waterDepthM
? `${formatDim(berth.waterDepth, berth.waterDepthM)}${berth.waterDepthIsMinimum ? ' (min)' : ''}`
: null
}
/>
<SpecRow label="Mooring Type" value={berth.mooringType} />
<SpecRow label="Side Pontoon" value={berth.sidePontoon} />
<SpecRow label="Bow Facing" value={berth.bowFacing} />
<SpecRow label="Access" value={berth.access} />
<SpecRow label="Approved" value={berth.berthApproved ? 'Yes' : null} />
</CardContent>
</Card>
{/* Infrastructure & Pricing */}
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Infrastructure</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow label="Power Capacity" value={berth.powerCapacity} />
<SpecRow label="Voltage" value={berth.voltage} />
<SpecRow label="Cleat Type" value={berth.cleatType} />
<SpecRow label="Cleat Capacity" value={berth.cleatCapacity} />
<SpecRow label="Bollard Type" value={berth.bollardType} />
<SpecRow label="Bollard Capacity" value={berth.bollardCapacity} />
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tenure & Pricing</CardTitle>
</CardHeader>
<CardContent className="pt-0 divide-y">
<SpecRow
label="Tenure Type"
value={berth.tenureType === 'permanent' ? 'Permanent' : 'Fixed Term'}
/>
{berth.tenureType === 'fixed_term' && (
<>
<SpecRow label="Years" value={berth.tenureYears} />
<SpecRow label="Start Date" value={berth.tenureStartDate} />
<SpecRow label="End Date" value={berth.tenureEndDate} />
</>
)}
<SpecRow label="Price" value={price} />
</CardContent>
</Card>
{berth.tags.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Tags</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1.5">
{berth.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
</CardContent>
</Card>
)}
</div>
</div>
);
}
function StubTab({ label }: { label: string }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<p className="text-muted-foreground">{label} coming soon</p>
</div>
);
}
export function buildBerthTabs(berth: BerthData): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab berth={berth} />,
},
{
id: 'interests',
label: 'Interests',
content: <StubTab label="Interests" />,
},
{
id: 'waiting-list',
label: 'Waiting List',
content: <StubTab label="Waiting List" />,
},
{
id: 'maintenance',
label: 'Maintenance Log',
content: <StubTab label="Maintenance Log" />,
},
{
id: 'activity',
label: 'Activity',
content: <StubTab label="Activity" />,
},
];
}

View File

@@ -0,0 +1,269 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
type DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Plus, Loader2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { apiFetch } from '@/lib/api/client';
interface WaitingListEntry {
id: string;
clientId: string;
position: number;
priority: string;
notifyPref: string;
notes: string | null;
createdAt: string;
}
interface WaitingListManagerProps {
berthId: string;
}
function SortableEntry({
entry,
onRemove,
}: {
entry: WaitingListEntry;
onRemove: (id: string) => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: entry.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-3 border rounded-md p-3 bg-card"
>
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-muted-foreground"
>
<GripVertical className="h-4 w-4" />
</button>
<span className="text-sm font-mono w-6 text-center text-muted-foreground">
{entry.position}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{entry.clientId}</p>
{entry.notes && (
<p className="text-xs text-muted-foreground truncate">{entry.notes}</p>
)}
</div>
<Badge variant={entry.priority === 'high' ? 'destructive' : 'secondary'}>
{entry.priority}
</Badge>
<button
onClick={() => onRemove(entry.id)}
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
);
}
export function WaitingListManager({ berthId }: WaitingListManagerProps) {
const queryClient = useQueryClient();
const sensors = useSensors(useSensor(PointerSensor));
const [showAddForm, setShowAddForm] = useState(false);
const [newClientId, setNewClientId] = useState('');
const [newPriority, setNewPriority] = useState<'normal' | 'high'>('normal');
const [newNotes, setNewNotes] = useState('');
const { data, isLoading } = useQuery<{ data: WaitingListEntry[] }>({
queryKey: ['berth-waiting-list', berthId],
queryFn: () => apiFetch(`/api/v1/berths/${berthId}/waiting-list`),
});
const reorderMutation = useMutation({
mutationFn: (body: { entryId: string; newPosition: number }) =>
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
},
});
const addMutation = useMutation({
mutationFn: (entries: WaitingListEntry[]) =>
apiFetch(`/api/v1/berths/${berthId}/waiting-list`, {
method: 'PUT',
body: JSON.stringify({ entries }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['berth-waiting-list', berthId] });
setShowAddForm(false);
setNewClientId('');
setNewNotes('');
},
});
const entries = data?.data ?? [];
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const overId = over.id as string;
const overEntry = entries.find((e) => e.id === overId);
if (!overEntry) return;
reorderMutation.mutate({
entryId: active.id as string,
newPosition: overEntry.position,
});
}
function handleAdd() {
if (!newClientId.trim()) return;
const newEntry = {
clientId: newClientId.trim(),
position: entries.length + 1,
priority: newPriority,
notifyPref: 'email' as const,
notes: newNotes || undefined,
};
addMutation.mutate([
...entries.map((e) => ({
...e,
notifyPref: e.notifyPref as 'email' | 'in_app' | 'both',
priority: e.priority as 'normal' | 'high',
})),
newEntry as WaitingListEntry,
]);
}
function handleRemove(entryId: string) {
const remaining = entries
.filter((e) => e.id !== entryId)
.map((e, i) => ({
...e,
position: i + 1,
notifyPref: e.notifyPref as 'email' | 'in_app' | 'both',
priority: e.priority as 'normal' | 'high',
}));
addMutation.mutate(remaining);
}
if (isLoading) {
return <div className="h-24 bg-muted animate-pulse rounded" />;
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Waiting List ({entries.length})</span>
<Button size="sm" variant="outline" onClick={() => setShowAddForm((v) => !v)}>
<Plus className="mr-1.5 h-4 w-4" />
Add
</Button>
</div>
{showAddForm && (
<div className="border rounded-md p-3 space-y-3 bg-muted/30">
<Input
placeholder="Client ID"
value={newClientId}
onChange={(e) => setNewClientId(e.target.value)}
/>
<Select
value={newPriority}
onValueChange={(v) => setNewPriority(v as 'normal' | 'high')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="normal">Normal priority</SelectItem>
<SelectItem value="high">High priority</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="Notes (optional)"
value={newNotes}
onChange={(e) => setNewNotes(e.target.value)}
/>
<div className="flex gap-2">
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending && (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
)}
Add to List
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowAddForm(false)}>
Cancel
</Button>
</div>
</div>
)}
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No entries on waiting list.
</p>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={entries.map((e) => e.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{entries.map((entry) => (
<SortableEntry
key={entry.id}
entry={entry}
onRemove={handleRemove}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
);
}

View File

@@ -0,0 +1,164 @@
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
export interface ClientRow {
id: string;
fullName: string;
companyName: string | null;
source: string | null;
archivedAt: string | null;
createdAt: string;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean }>;
tags?: Array<{ id: string; name: string; color: string }>;
}
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface GetColumnsOptions {
portSlug: string;
onEdit: (client: ClientRow) => void;
onArchive: (client: ClientRow) => void;
}
export function getClientColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<ClientRow, unknown>[] {
return [
{
id: 'fullName',
accessorKey: 'fullName',
header: 'Name',
cell: ({ row }) => (
<Link
href={`/${portSlug}/clients/${row.original.id}`}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.fullName}
</Link>
),
},
{
id: 'companyName',
accessorKey: 'companyName',
header: 'Company',
cell: ({ getValue }) => (
<span className="text-muted-foreground">{(getValue() as string | null) ?? '—'}</span>
),
},
{
id: 'primaryContact',
header: 'Primary Contact',
enableSorting: false,
cell: ({ row }) => {
const primary = row.original.contacts?.find((c) => c.isPrimary);
if (!primary) return <span className="text-muted-foreground"></span>;
return (
<span className="text-sm">
<span className="text-muted-foreground capitalize">{primary.channel}: </span>
{primary.value}
</span>
);
},
},
{
id: 'source',
accessorKey: 'source',
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{SOURCE_LABELS[source] ?? source}
</Badge>
);
},
},
{
id: 'tags',
header: 'Tags',
enableSorting: false,
cell: ({ row }) => {
const clientTags = row.original.tags ?? [];
if (clientTags.length === 0) return <span className="text-muted-foreground"></span>;
return (
<div className="flex flex-wrap gap-1">
{clientTags.slice(0, 3).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{clientTags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{clientTags.length - 3}
</Badge>
)}
</div>
);
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: 'Created',
cell: ({ getValue }) => (
<span className="text-muted-foreground text-sm">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, Mail, Phone } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { ClientForm } from '@/components/clients/client-form';
import { apiFetch } from '@/lib/api/client';
interface ClientDetailHeaderProps {
client: {
id: string;
fullName: string;
companyName?: string | null;
nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
archivedAt?: string | null;
contacts?: Array<{ channel: string; value: string; isPrimary: boolean; label?: string | null }>;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
export function ClientDetailHeader({ client }: ClientDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const isArchived = !!client.archivedAt;
const archiveMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${client.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
setArchiveOpen(false);
},
});
const restoreMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/clients/${client.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients', client.id] });
queryClient.invalidateQueries({ queryKey: ['clients'] });
setArchiveOpen(false);
},
});
const primaryContact = client.contacts?.find((c) => c.isPrimary);
const primaryEmail = client.contacts?.find((c) => c.channel === 'email' && c.isPrimary)
?? client.contacts?.find((c) => c.channel === 'email');
const primaryPhone = client.contacts?.find((c) => c.channel === 'phone' && c.isPrimary)
?? client.contacts?.find((c) => c.channel === 'phone');
return (
<>
<div className="space-y-3">
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground truncate">
{client.fullName}
</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
{client.isProxy && (
<Badge variant="outline" className="text-xs capitalize">
Proxy {client.proxyType ? `(${client.proxyType.replace('_', ' ')})` : ''}
</Badge>
)}
</div>
{client.companyName && (
<p className="text-muted-foreground mt-0.5">{client.companyName}</p>
)}
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
{client.source && (
<span>
Source:{' '}
<span className="text-foreground">
{SOURCE_LABELS[client.source] ?? client.source}
</span>
</span>
)}
{primaryEmail && (
<span className="flex items-center gap-1">
<Mail className="h-3.5 w-3.5" />
{primaryEmail.value}
</span>
)}
{primaryPhone && (
<span className="flex items-center gap-1">
<Phone className="h-3.5 w-3.5" />
{primaryPhone.value}
</span>
)}
</div>
{client.tags && client.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{client.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setEditOpen(true)}
>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant={isArchived ? 'outline' : 'outline'}
size="sm"
onClick={() => setArchiveOpen(true)}
>
{isArchived ? (
<>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore
</>
) : (
<>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</>
)}
</Button>
</div>
</div>
</div>
<ClientForm
open={editOpen}
onOpenChange={setEditOpen}
client={client as any}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={client.fullName}
entityType="Client"
isArchived={isArchived}
onConfirm={() => {
if (isArchived) {
restoreMutation.mutate();
} else {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
</>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { ClientDetailHeader } from '@/components/clients/client-detail-header';
import { getClientTabs } from '@/components/clients/client-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface ClientData {
id: string;
portId: string;
fullName: string;
companyName: string | null;
nationality: string | null;
isProxy: boolean;
proxyType: string | null;
actualOwnerName: string | null;
yachtName: string | null;
yachtLengthFt: string | null;
yachtWidthFt: string | null;
yachtDraftFt: string | null;
yachtLengthM: string | null;
yachtWidthM: string | null;
yachtDraftM: string | null;
berthSizeDesired: string | null;
preferredContactMethod: string | null;
preferredLanguage: string | null;
timezone: string | null;
source: string | null;
sourceDetails: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
contacts: Array<{
id: string;
channel: string;
value: string;
label: string | null;
isPrimary: boolean;
notes: string | null;
}>;
tags: Array<{
id: string;
name: string;
color: string;
}>;
}
interface ClientDetailProps {
clientId: string;
currentUserId?: string;
}
export function ClientDetail({ clientId, currentUserId }: ClientDetailProps) {
const { data, isLoading } = useQuery<ClientData>({
queryKey: ['clients', clientId],
queryFn: () =>
apiFetch<{ data: ClientData }>(`/api/v1/clients/${clientId}`).then((r) => r.data),
});
useRealtimeInvalidation({
'client:updated': [['clients', clientId]],
'client:archived': [['clients', clientId]],
'client:restored': [['clients', clientId]],
});
const tabs = data
? getClientTabs({ clientId, currentUserId, client: data })
: [];
return (
<DetailLayout
header={data ? <ClientDetailHeader client={data} /> : null}
tabs={tabs}
defaultTab="overview"
isLoading={isLoading}
/>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
interface ClientFilesTabProps {
clientId: string;
}
export function ClientFilesTab({ clientId }: ClientFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { clientId }],
endpoint: `/api/v1/files?clientId=${encodeURIComponent(clientId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files', { clientId }]],
'file:updated': [['files', { clientId }]],
'file:deleted': [['files', { clientId }]],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
} catch {
// silent
}
};
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
clientId={clientId}
onUploadComplete={() => {
queryClient.invalidateQueries({ queryKey: ['files', { clientId }] });
}}
/>
</PermissionGate>
<FileGrid
files={data}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={isLoading}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const clientFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by name or company...',
},
{
key: 'source',
label: 'Source',
type: 'select',
options: [
{ label: 'Website', value: 'website' },
{ label: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' },
],
},
{
key: 'nationality',
label: 'Nationality',
type: 'text',
placeholder: 'Filter by nationality...',
},
{
key: 'isProxy',
label: 'Proxy Client',
type: 'boolean',
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,436 @@
'use client';
import { useEffect } from 'react';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Trash2, Loader2 } from 'lucide-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 { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
interface ClientFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** If provided, form is in edit mode */
client?: {
id: string;
fullName: string;
companyName?: string | null;
nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
contacts?: Array<{
channel: string;
value: string;
label?: string | null;
isPrimary?: boolean;
}>;
tags?: Array<{ id: string }>;
};
}
export function ClientForm({ open, onOpenChange, client }: ClientFormProps) {
const queryClient = useQueryClient();
const isEdit = !!client;
const {
register,
handleSubmit,
control,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateClientInput>({
resolver: zodResolver(createClientSchema),
defaultValues: {
fullName: '',
contacts: [{ channel: 'email', value: '', isPrimary: true }],
isProxy: false,
tagIds: [],
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
const isProxy = watch('isProxy');
const tagIds = watch('tagIds') ?? [];
// Populate form when editing
useEffect(() => {
if (client && open) {
reset({
fullName: client.fullName,
companyName: client.companyName ?? undefined,
nationality: client.nationality ?? undefined,
isProxy: client.isProxy ?? false,
proxyType: client.proxyType ?? undefined,
actualOwnerName: client.actualOwnerName ?? undefined,
yachtName: client.yachtName ?? undefined,
berthSizeDesired: client.berthSizeDesired ?? undefined,
preferredContactMethod: (client.preferredContactMethod as any) ?? undefined,
preferredLanguage: client.preferredLanguage ?? undefined,
timezone: client.timezone ?? undefined,
source: (client.source as any) ?? undefined,
sourceDetails: client.sourceDetails ?? undefined,
contacts:
client.contacts && client.contacts.length > 0
? client.contacts.map((c) => ({
channel: c.channel as any,
value: c.value,
label: c.label ?? undefined,
isPrimary: c.isPrimary ?? false,
}))
: [{ channel: 'email', value: '', isPrimary: true }],
tagIds: client.tags?.map((t) => t.id) ?? [],
});
} else if (!client && open) {
reset({
fullName: '',
contacts: [{ channel: 'email', value: '', isPrimary: true }],
isProxy: false,
tagIds: [],
});
}
}, [client, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateClientInput) => {
if (isEdit) {
const { contacts, tagIds: tIds, ...rest } = data;
await apiFetch(`/api/v1/clients/${client!.id}`, { method: 'PATCH', body: rest });
if (tIds) {
await apiFetch(`/api/v1/clients/${client!.id}/tags`, {
method: 'PUT',
body: { tagIds: tIds },
});
}
} else {
await apiFetch('/api/v1/clients', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
onOpenChange(false);
},
});
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Client' : 'New Client'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => mutation.mutate(data))}
className="space-y-6 py-6"
>
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Basic Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Full Name *</Label>
<Input {...register('fullName')} placeholder="John Smith" />
{errors.fullName && (
<p className="text-xs text-destructive">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Company Name</Label>
<Input {...register('companyName')} placeholder="Acme Corp" />
</div>
<div className="space-y-1">
<Label>Nationality</Label>
<Input {...register('nationality')} placeholder="British" />
</div>
</div>
</div>
<Separator />
{/* Contacts */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Contacts
</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ channel: 'email', value: '', isPrimary: false })
}
>
<Plus className="mr-1 h-3.5 w-3.5" />
Add Contact
</Button>
</div>
{errors.contacts?.root && (
<p className="text-xs text-destructive">{errors.contacts.root.message}</p>
)}
<div className="space-y-3">
{fields.map((field, index) => (
<div
key={field.id}
className="grid grid-cols-12 gap-2 items-end p-3 rounded-lg border bg-muted/30"
>
<div className="col-span-3 space-y-1">
<Label className="text-xs">Channel</Label>
<Select
value={watch(`contacts.${index}.channel`)}
onValueChange={(v) =>
setValue(`contacts.${index}.channel`, v as any)
}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="whatsapp">WhatsApp</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="col-span-5 space-y-1">
<Label className="text-xs">Value</Label>
<Input
{...register(`contacts.${index}.value`)}
className="h-8"
placeholder="email@example.com"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">Label</Label>
<Input
{...register(`contacts.${index}.label`)}
className="h-8"
placeholder="work"
/>
</div>
<div className="col-span-1 flex items-center gap-1 pb-1">
<Checkbox
checked={watch(`contacts.${index}.isPrimary`)}
onCheckedChange={(v) =>
setValue(`contacts.${index}.isPrimary`, !!v)
}
/>
<Label className="text-xs">Primary</Label>
</div>
<div className="col-span-1 flex justify-end pb-1">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
</div>
<Separator />
{/* Proxy */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Proxy Information
</h3>
<div className="flex items-center gap-2">
<Checkbox
id="isProxy"
checked={watch('isProxy')}
onCheckedChange={(v) => setValue('isProxy', !!v)}
/>
<Label htmlFor="isProxy">This is a proxy client</Label>
</div>
{isProxy && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Proxy Type</Label>
<Select
value={watch('proxyType') ?? ''}
onValueChange={(v) => setValue('proxyType', v)}
>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="broker">Broker</SelectItem>
<SelectItem value="representative">Representative</SelectItem>
<SelectItem value="family_member">Family Member</SelectItem>
<SelectItem value="legal_counsel">Legal Counsel</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Actual Owner Name</Label>
<Input
{...register('actualOwnerName')}
placeholder="Actual owner"
/>
</div>
</div>
)}
</div>
<Separator />
{/* Yacht Details */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Yacht Details
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2 space-y-1">
<Label>Yacht Name</Label>
<Input {...register('yachtName')} placeholder="My Yacht" />
</div>
<div className="space-y-1">
<Label>Berth Size Desired</Label>
<Input {...register('berthSizeDesired')} placeholder="e.g. 30m" />
</div>
</div>
</div>
<Separator />
{/* Source & Preferences */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Source & Preferences
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Source</Label>
<Select
value={watch('source') ?? ''}
onValueChange={(v) => setValue('source', v as any)}
>
<SelectTrigger>
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Preferred Contact Method</Label>
<Select
value={watch('preferredContactMethod') ?? ''}
onValueChange={(v) => setValue('preferredContactMethod', v as any)}
>
<SelectTrigger>
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="phone">Phone</SelectItem>
<SelectItem value="whatsapp">WhatsApp</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Preferred Language</Label>
<Input {...register('preferredLanguage')} placeholder="English" />
</div>
<div className="space-y-1">
<Label>Timezone</Label>
<Input {...register('timezone')} placeholder="UTC+0" />
</div>
<div className="col-span-2 space-y-1">
<Label>Source Details</Label>
<Input
{...register('sourceDetails')}
placeholder="Referred by John Doe"
/>
</div>
</div>
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker
selectedIds={tagIds}
onChange={(ids) => setValue('tagIds', ids)}
/>
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Client'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { ClientForm } from '@/components/clients/client-form';
import { clientFilterDefinitions } from '@/components/clients/client-filters';
import { getClientColumns, type ClientRow } from '@/components/clients/client-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
export function ClientList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editClient, setEditClient] = useState<ClientRow | null>(null);
const [archiveClient, setArchiveClient] = useState<ClientRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<ClientRow>({
queryKey: ['clients'],
endpoint: '/api/v1/clients',
filterDefinitions: clientFilterDefinitions,
});
useRealtimeInvalidation({
'client:created': [['clients']],
'client:updated': [['clients']],
'client:archived': [['clients']],
'client:restored': [['clients']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/clients/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clients'] });
setArchiveClient(null);
},
});
const columns = getClientColumns({
portSlug,
onEdit: (client) => setEditClient(client),
onArchive: (client) => setArchiveClient(client),
});
return (
<div className="space-y-4">
<PageHeader
title="Clients"
description="Manage your client records"
actions={
<PermissionGate resource="clients" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Client
</Button>
</PermissionGate>
}
/>
<div className="flex items-center gap-2">
<FilterBar
filters={clientFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<SavedViewsDropdown
entityType="clients"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters, savedSort) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
</div>
{isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No clients found"
description="Get started by adding your first client."
action={{ label: 'New Client', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<ClientForm
open={createOpen}
onOpenChange={setCreateOpen}
/>
{editClient && (
<ClientForm
open={!!editClient}
onOpenChange={(open) => !open && setEditClient(null)}
client={editClient as any}
/>
)}
<ArchiveConfirmDialog
open={!!archiveClient}
onOpenChange={(open) => !open && setArchiveClient(null)}
entityName={archiveClient?.fullName ?? ''}
entityType="Client"
isArchived={false}
onConfirm={() => archiveClient && archiveMutation.mutate(archiveClient.id)}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,208 @@
'use client';
import type { DetailTab } from '@/components/shared/detail-layout';
import { NotesList } from '@/components/shared/notes-list';
interface ClientTabsOptions {
clientId: string;
currentUserId?: string;
client: {
fullName: string;
companyName?: string | null;
nationality?: string | null;
isProxy?: boolean;
proxyType?: string | null;
actualOwnerName?: string | null;
yachtName?: string | null;
yachtLengthFt?: string | null;
yachtWidthFt?: string | null;
yachtDraftFt?: string | null;
berthSizeDesired?: string | null;
preferredContactMethod?: string | null;
preferredLanguage?: string | null;
timezone?: string | null;
source?: string | null;
sourceDetails?: string | null;
contacts?: Array<{
id: string;
channel: string;
value: string;
label?: string | null;
isPrimary: boolean;
}>;
};
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<dt className="w-40 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
</div>
);
}
function OverviewTab({ client }: { client: ClientTabsOptions['client'] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Personal Info */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Personal Information</h3>
<dl>
<InfoRow label="Full Name" value={client.fullName} />
<InfoRow label="Company" value={client.companyName} />
<InfoRow label="Nationality" value={client.nationality} />
<InfoRow label="Preferred Language" value={client.preferredLanguage} />
<InfoRow label="Timezone" value={client.timezone} />
<InfoRow
label="Preferred Contact"
value={client.preferredContactMethod}
/>
</dl>
</div>
{/* Contacts */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Contact Details</h3>
{client.contacts && client.contacts.length > 0 ? (
<div className="space-y-2">
{client.contacts.map((c) => (
<div
key={c.id}
className="flex items-center gap-2 p-2 rounded-lg border bg-muted/30 text-sm"
>
<span className="capitalize text-muted-foreground w-20 shrink-0">
{c.channel}
</span>
<span className="flex-1">{c.value}</span>
{c.label && (
<span className="text-xs text-muted-foreground capitalize">
{c.label}
</span>
)}
{c.isPrimary && (
<span className="text-xs font-medium text-primary">Primary</span>
)}
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">No contacts added</p>
)}
</div>
{/* Yacht Details */}
{(client.yachtName ||
client.yachtLengthFt ||
client.berthSizeDesired) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Yacht Details</h3>
<dl>
<InfoRow label="Yacht Name" value={client.yachtName} />
<InfoRow
label="Length"
value={
client.yachtLengthFt
? `${client.yachtLengthFt} ft`
: undefined
}
/>
<InfoRow
label="Width"
value={
client.yachtWidthFt ? `${client.yachtWidthFt} ft` : undefined
}
/>
<InfoRow
label="Draft"
value={
client.yachtDraftFt
? `${client.yachtDraftFt} ft`
: undefined
}
/>
<InfoRow label="Berth Size Desired" value={client.berthSizeDesired} />
</dl>
</div>
)}
{/* Source */}
{(client.source || client.sourceDetails) && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Source</h3>
<dl>
<InfoRow label="Source" value={client.source} />
<InfoRow label="Source Details" value={client.sourceDetails} />
</dl>
</div>
)}
{/* Proxy Info */}
{client.isProxy && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Proxy Information</h3>
<dl>
<InfoRow
label="Proxy Type"
value={client.proxyType?.replace('_', ' ')}
/>
<InfoRow label="Actual Owner" value={client.actualOwnerName} />
</dl>
</div>
)}
</div>
);
}
export function getClientTabs({
clientId,
currentUserId,
client,
}: ClientTabsOptions): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab client={client} />,
},
{
id: 'interests',
label: 'Interests',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Interests will appear here once created.</p>
</div>
),
},
{
id: 'notes',
label: 'Notes',
content: (
<NotesList
entityType="clients"
entityId={clientId}
currentUserId={currentUserId}
/>
),
},
{
id: 'files',
label: 'Files',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>File attachments coming soon.</p>
</div>
),
},
{
id: 'activity',
label: 'Activity',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Activity log coming soon.</p>
</div>
),
},
];
}

View File

@@ -0,0 +1,97 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary';
interface ActivityItem {
id: string;
action: string;
entityType: string;
entityId: string | null;
userId: string | null;
metadata: Record<string, unknown> | null;
createdAt: string;
}
const ACTION_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
create: 'default',
update: 'secondary',
delete: 'destructive',
archive: 'outline',
restore: 'secondary',
};
function ActionBadge({ action }: { action: string }) {
const variant = ACTION_VARIANTS[action] ?? 'outline';
return (
<Badge variant={variant} className="shrink-0 capitalize text-xs">
{action}
</Badge>
);
}
function ActivityFeedInner() {
const { data, isLoading } = useQuery<ActivityItem[]>({
queryKey: ['dashboard', 'activity'],
queryFn: () => apiFetch<ActivityItem[]>('/api/v1/dashboard/activity'),
staleTime: 30_000,
retry: 2,
});
if (isLoading) {
return <CardSkeleton />;
}
const items = data ?? [];
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">No recent activity.</p>
) : (
<div className="max-h-80 overflow-y-auto space-y-3 pr-1">
{items.map((item) => (
<div
key={item.id}
className="flex items-start gap-3 text-sm border-b border-border pb-3 last:border-0 last:pb-0"
>
<ActionBadge action={item.action} />
<div className="min-w-0 flex-1">
<p className="truncate text-foreground">
<span className="font-medium capitalize">{item.entityType}</span>
{item.entityId && (
<span className="ml-1 text-muted-foreground font-mono text-xs">
{item.entityId.slice(0, 8)}
</span>
)}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{formatDistanceToNow(new Date(item.createdAt), { addSuffix: true })}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
export function ActivityFeed() {
return (
<WidgetErrorBoundary>
<ActivityFeedInner />
</WidgetErrorBoundary>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { KpiCardsWithBoundary } from './kpi-cards';
import { PipelineChart } from './pipeline-chart';
import { RevenueForecast } from './revenue-forecast';
import { ActivityFeed } from './activity-feed';
export function DashboardShell() {
useRealtimeInvalidation({
'interest:stageChanged': [['dashboard', 'pipeline'], ['dashboard', 'forecast']],
'client:created': [['dashboard', 'kpis']],
'berth:statusChanged': [['dashboard', 'kpis'], ['dashboard', 'forecast']],
});
return (
<div className="space-y-6">
{/* Row 1: KPI cards */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4">
<KpiCardsWithBoundary />
</div>
{/* Row 2: Pipeline chart + Revenue forecast */}
<div className="grid gap-4 grid-cols-1 lg:grid-cols-3">
<div className="lg:col-span-2">
<PipelineChart />
</div>
<div className="lg:col-span-1">
<RevenueForecast />
</div>
</div>
{/* Row 3: Activity feed */}
<ActivityFeed />
</div>
);
}

View File

@@ -0,0 +1,98 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { DollarSign, LayoutGrid, TrendingUp, Users } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary';
interface KpiData {
totalClients: number;
activeInterests: number;
pipelineValueUsd: number;
occupancyRate: number;
}
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(value);
}
function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
export function KpiCards() {
const { data, isLoading, isError } = useQuery<KpiData>({
queryKey: ['dashboard', 'kpis'],
queryFn: () => apiFetch<KpiData>('/api/v1/dashboard/kpis'),
staleTime: 60_000,
retry: 2,
});
if (isLoading) {
return (
<>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</>
);
}
const kpis = [
{
label: 'Total Clients',
value: isError ? '—' : String(data?.totalClients ?? 0),
icon: Users,
},
{
label: 'Active Interests',
value: isError ? '—' : String(data?.activeInterests ?? 0),
icon: TrendingUp,
},
{
label: 'Pipeline Value',
value: isError ? '—' : formatCurrency(data?.pipelineValueUsd ?? 0),
icon: DollarSign,
},
{
label: 'Occupancy Rate',
value: isError ? '—' : formatPercent(data?.occupancyRate ?? 0),
icon: LayoutGrid,
},
];
return (
<>
{kpis.map(({ label, value, icon: Icon }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{label}
</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<div className="mt-1 h-1 w-8 rounded-full bg-muted" />
</CardContent>
</Card>
))}
</>
);
}
export function KpiCardsWithBoundary() {
return (
<WidgetErrorBoundary>
<KpiCards />
</WidgetErrorBoundary>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary';
interface PipelineRow {
stage: string;
count: number;
}
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
function PipelineChartInner() {
const { data, isLoading } = useQuery<PipelineRow[]>({
queryKey: ['dashboard', 'pipeline'],
queryFn: () => apiFetch<PipelineRow[]>('/api/v1/dashboard/pipeline'),
staleTime: 60_000,
retry: 2,
});
if (isLoading) {
return <CardSkeleton />;
}
const chartData = (data ?? []).map((row) => ({
stage: STAGE_LABELS[row.stage] ?? row.stage,
count: row.count,
}));
return (
<Card className="h-full">
<CardHeader>
<CardTitle className="text-base">Pipeline Overview</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={260}>
<BarChart data={chartData} margin={{ top: 4, right: 8, left: -16, bottom: 60 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis
dataKey="stage"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
angle={-40}
textAnchor="end"
interval={0}
/>
<YAxis
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
background: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: 12,
}}
/>
<Bar dataKey="count" fill="hsl(var(--chart-1))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
export function PipelineChart() {
return (
<WidgetErrorBoundary>
<PipelineChartInner />
</WidgetErrorBoundary>
);
}

View File

@@ -0,0 +1,104 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CardSkeleton } from '@/components/shared/loading-skeleton';
import { WidgetErrorBoundary } from './widget-error-boundary';
interface StageBreakdownRow {
stage: string;
count: number;
weightedValue: number;
}
interface ForecastData {
totalWeightedValue: number;
stageBreakdown: StageBreakdownRow[];
weightsSource: 'db' | 'default';
}
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
function formatCurrency(value: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(value);
}
function RevenueForecastInner() {
const { data, isLoading } = useQuery<ForecastData>({
queryKey: ['dashboard', 'forecast'],
queryFn: () => apiFetch<ForecastData>('/api/v1/dashboard/forecast'),
staleTime: 60_000,
retry: 2,
});
if (isLoading) {
return <CardSkeleton />;
}
const activeStages = (data?.stageBreakdown ?? []).filter((s) => s.count > 0);
return (
<Card className="h-full">
<CardHeader className="flex flex-row items-start justify-between gap-2">
<CardTitle className="text-base">Revenue Forecast</CardTitle>
{data?.weightsSource === 'default' && (
<Badge variant="secondary" className="shrink-0 text-xs">
Using default weights
</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-xs text-muted-foreground">Weighted Pipeline Value</p>
<p className="text-2xl font-bold">
{formatCurrency(data?.totalWeightedValue ?? 0)}
</p>
</div>
{activeStages.length > 0 && (
<div className="space-y-2">
{activeStages.map((s) => (
<div key={s.stage} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{STAGE_LABELS[s.stage] ?? s.stage}
<span className="ml-1 text-xs">({s.count})</span>
</span>
<span className="font-medium tabular-nums">
{formatCurrency(s.weightedValue)}
</span>
</div>
))}
</div>
)}
{activeStages.length === 0 && (
<p className="text-sm text-muted-foreground">No active interests with linked berths.</p>
)}
</CardContent>
</Card>
);
}
export function RevenueForecast() {
return (
<WidgetErrorBoundary>
<RevenueForecastInner />
</WidgetErrorBoundary>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import React, { Component, type ReactNode } from 'react';
import { Card, CardContent } from '@/components/ui/card';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class WidgetErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
reset = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-8">
<p className="text-sm text-muted-foreground">Widget unavailable</p>
<button
onClick={this.reset}
className="text-xs text-muted-foreground underline-offset-2 hover:underline"
>
Retry
</button>
</CardContent>
</Card>
);
}
return this.props.children;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,200 @@
'use client';
import { useState, useCallback } from 'react';
import { Loader2, Mail, Copy, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from '@/components/ui/sheet';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { apiFetch } from '@/lib/api/client';
// ─── Types ────────────────────────────────────────────────────────────────────
interface EmailDraftButtonProps {
interestId: string;
clientId: string;
context?: 'follow_up' | 'introduction' | 'stage_update' | 'general';
additionalInstructions?: string;
}
interface DraftResult {
subject: string;
body: string;
generatedAt: string;
}
// ─── Polling helper ───────────────────────────────────────────────────────────
const POLL_INTERVAL_MS = 1500;
const POLL_MAX_ATTEMPTS = 20; // 30 s total
async function pollDraftResult(jobId: string): Promise<DraftResult> {
for (let attempt = 0; attempt < POLL_MAX_ATTEMPTS; attempt++) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
const res = await apiFetch<{ status: string; data?: DraftResult }>(
`/api/v1/ai/email-draft/${jobId}`,
);
if (res.status === 'complete' && res.data) {
return res.data;
}
}
throw new Error('Email draft generation timed out. Please try again.');
}
// ─── Component ────────────────────────────────────────────────────────────────
export function EmailDraftButton({
interestId,
clientId,
context = 'follow_up',
additionalInstructions,
}: EmailDraftButtonProps) {
const featureEnabled = useFeatureFlag('ai_email_drafts');
const [isLoading, setIsLoading] = useState(false);
const [sheetOpen, setSheetOpen] = useState(false);
const [draft, setDraft] = useState<DraftResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const handleGenerateDraft = useCallback(async () => {
setIsLoading(true);
setError(null);
setDraft(null);
try {
const { jobId } = await apiFetch<{ jobId: string }>('/api/v1/ai/email-draft', {
method: 'POST',
body: { interestId, clientId, context, additionalInstructions },
});
const result = await pollDraftResult(jobId);
setDraft(result);
setSheetOpen(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate draft');
} finally {
setIsLoading(false);
}
}, [interestId, clientId, context, additionalInstructions]);
const handleCopy = useCallback(async () => {
if (!draft) return;
const text = `Subject: ${draft.subject}\n\n${draft.body}`;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [draft]);
if (!featureEnabled) return null;
return (
<>
<Button
variant="outline"
size="sm"
onClick={handleGenerateDraft}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Generating...
</>
) : (
<>
<Mail className="mr-1.5 h-3.5 w-3.5" />
Draft Email
</>
)}
</Button>
{error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent side="right" className="w-full max-w-lg flex flex-col">
<SheetHeader>
<SheetTitle>Generated Email Draft</SheetTitle>
<SheetDescription>
Review and edit the draft before sending.
</SheetDescription>
</SheetHeader>
{draft && (
<div className="flex-1 overflow-y-auto mt-4 space-y-4">
{/* Subject */}
<div>
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Subject
</label>
<p
contentEditable
suppressContentEditableWarning
className="mt-1 text-sm font-medium border rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-ring"
>
{draft.subject}
</p>
</div>
{/* Body */}
<div className="flex-1">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Body
</label>
<pre
contentEditable
suppressContentEditableWarning
className="mt-1 text-sm whitespace-pre-wrap font-sans border rounded-md px-3 py-2 min-h-[300px] focus:outline-none focus:ring-2 focus:ring-ring"
>
{draft.body}
</pre>
</div>
{/* Actions */}
<div className="flex gap-2 pt-2 border-t">
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied ? (
<>
<Check className="mr-1.5 h-3.5 w-3.5 text-green-600" />
Copied
</>
) : (
<>
<Copy className="mr-1.5 h-3.5 w-3.5" />
Copy to clipboard
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleGenerateDraft}
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Regenerating...
</>
) : (
'Regenerate'
)}
</Button>
</div>
</div>
)}
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,183 @@
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive, Eye } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export interface ExpenseRow {
id: string;
establishmentName: string | null;
amount: string;
currency: string;
amountUsd: string | null;
category: string | null;
paymentStatus: string | null;
paymentMethod: string | null;
expenseDate: string;
description: string | null;
payer: string | null;
receiptFileIds: string[] | null;
archivedAt: string | null;
createdAt: string;
}
const PAYMENT_STATUS_VARIANTS: Record<string, string> = {
unpaid: 'destructive',
paid: 'default',
partial: 'secondary',
};
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
paid: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
};
interface GetColumnsOptions {
portSlug: string;
onEdit: (expense: ExpenseRow) => void;
onArchive: (expense: ExpenseRow) => void;
}
export function getExpenseColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<ExpenseRow, unknown>[] {
return [
{
id: 'expenseDate',
accessorKey: 'expenseDate',
header: 'Date',
cell: ({ getValue }) => (
<span className="text-sm text-muted-foreground">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
{
id: 'establishmentName',
accessorKey: 'establishmentName',
header: 'Establishment',
cell: ({ row }) => (
<Link
href={`/${portSlug}/expenses/${row.original.id}`}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.establishmentName ?? '—'}
</Link>
),
},
{
id: 'amount',
header: 'Amount',
enableSorting: false,
cell: ({ row }) => (
<span className="font-medium tabular-nums">
{Number(row.original.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{row.original.currency}
</span>
),
},
{
id: 'amountUsd',
header: 'USD Equiv.',
enableSorting: false,
cell: ({ row }) =>
row.original.amountUsd ? (
<span className="text-sm text-muted-foreground tabular-nums">
${Number(row.original.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
) : (
<span className="text-sm text-muted-foreground">N/A</span>
),
},
{
id: 'category',
accessorKey: 'category',
header: 'Category',
cell: ({ getValue }) => {
const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>;
return (
<Badge variant="outline" className="capitalize text-xs">
{cat.replace(/_/g, ' ')}
</Badge>
);
},
},
{
id: 'paymentStatus',
accessorKey: 'paymentStatus',
header: 'Status',
cell: ({ getValue }) => {
const status = (getValue() as string | null) ?? 'unpaid';
const colorClass = PAYMENT_STATUS_COLORS[status] ?? '';
return (
<Badge
variant="outline"
className={`capitalize text-xs border ${colorClass}`}
>
{status}
</Badge>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/expenses/${row.original.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}

View File

@@ -0,0 +1,203 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2, Receipt, Edit, Archive } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { apiFetch } from '@/lib/api/client';
import type { ExpenseRow } from './expense-columns';
const PAYMENT_STATUS_COLORS: Record<string, string> = {
unpaid: 'bg-red-100 text-red-700 border-red-200',
paid: 'bg-green-100 text-green-700 border-green-200',
partial: 'bg-yellow-100 text-yellow-700 border-yellow-200',
};
interface ExpenseDetailProps {
expenseId: string;
onEdit?: () => void;
onArchived?: () => void;
}
export function ExpenseDetail({ expenseId, onEdit, onArchived }: ExpenseDetailProps) {
const queryClient = useQueryClient();
const [archiveOpen, setArchiveOpen] = useState(false);
const { data, isLoading, error } = useQuery<{ data: ExpenseRow }>({
queryKey: ['expenses', expenseId],
queryFn: () => apiFetch(`/api/v1/expenses/${expenseId}`),
});
const archiveMutation = useMutation({
mutationFn: () => apiFetch(`/api/v1/expenses/${expenseId}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
setArchiveOpen(false);
onArchived?.();
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">
Failed to load expense details.
</div>
);
}
const expense = data.data;
const status = expense.paymentStatus ?? 'unpaid';
const statusColor = PAYMENT_STATUS_COLORS[status] ?? '';
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">
{expense.establishmentName ?? 'Unnamed Expense'}
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
{format(new Date(expense.expenseDate), 'MMMM d, yyyy')}
</p>
</div>
<div className="flex items-center gap-2">
{onEdit && (
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="mr-1.5 h-4 w-4" />
Edit
</Button>
)}
<Button
variant="outline"
size="sm"
className="text-destructive"
onClick={() => setArchiveOpen(true)}
>
<Archive className="mr-1.5 h-4 w-4" />
Archive
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Amount</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold tabular-nums">
{Number(expense.amount).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{expense.currency}
</p>
{expense.amountUsd && expense.currency !== 'USD' && (
<p className="text-sm text-muted-foreground mt-1">
${Number(expense.amountUsd).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})} USD
</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Payment Status</CardTitle>
</CardHeader>
<CardContent>
<Badge
variant="outline"
className={`capitalize text-sm border ${statusColor}`}
>
{status}
</Badge>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Details</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Category</span>
<p className="mt-0.5 capitalize">
{expense.category?.replace(/_/g, ' ') ?? '—'}
</p>
</div>
<div>
<span className="text-muted-foreground">Payment Method</span>
<p className="mt-0.5 capitalize">
{expense.paymentMethod?.replace(/_/g, ' ') ?? '—'}
</p>
</div>
<div>
<span className="text-muted-foreground">Payer</span>
<p className="mt-0.5">{expense.payer ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Description</span>
<p className="mt-0.5">{expense.description ?? '—'}</p>
</div>
</CardContent>
</Card>
{expense.receiptFileIds && expense.receiptFileIds.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium flex items-center gap-2">
<Receipt className="h-4 w-4" />
Receipts ({expense.receiptFileIds.length})
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(expense.receiptFileIds as string[]).map((fileId: string) => (
<Badge key={fileId} variant="secondary" className="font-mono text-xs">
{fileId}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={expense.establishmentName ?? 'this expense'}
entityType="Expense"
isArchived={false}
onConfirm={() => archiveMutation.mutate()}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
import { EXPENSE_CATEGORIES } from '@/lib/constants';
export const expenseFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by establishment or description...',
},
{
key: 'category',
label: 'Category',
type: 'multi-select',
options: EXPENSE_CATEGORIES.map((c) => ({
label: c.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
value: c,
})),
},
{
key: 'paymentStatus',
label: 'Payment Status',
type: 'select',
options: [
{ label: 'Unpaid', value: 'unpaid' },
{ label: 'Paid', value: 'paid' },
{ label: 'Partial', value: 'partial' },
],
},
{
key: 'dateFrom',
label: 'Date From',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'dateTo',
label: 'Date To',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'currency',
label: 'Currency',
type: 'text',
placeholder: 'e.g. USD, EUR',
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,261 @@
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
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 { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
import type { ExpenseRow } from './expense-columns';
interface ExpenseFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
expense?: ExpenseRow;
}
export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) {
const queryClient = useQueryClient();
const isEdit = !!expense;
const {
register,
handleSubmit,
setValue,
watch,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateExpenseInput>({
resolver: zodResolver(createExpenseSchema),
defaultValues: {
currency: 'USD',
paymentStatus: 'unpaid',
},
});
useEffect(() => {
if (open && expense) {
reset({
establishmentName: expense.establishmentName ?? undefined,
amount: Number(expense.amount),
currency: expense.currency,
category: expense.category as any,
paymentMethod: expense.paymentMethod as any,
payer: expense.payer ?? undefined,
expenseDate: new Date(expense.expenseDate),
paymentStatus: (expense.paymentStatus as any) ?? 'unpaid',
});
} else if (open && !expense) {
reset({
currency: 'USD',
paymentStatus: 'unpaid',
expenseDate: new Date(),
});
}
}, [open, expense, reset]);
const mutation = useMutation({
mutationFn: (data: CreateExpenseInput) => {
if (isEdit) {
return apiFetch(`/api/v1/expenses/${expense.id}`, {
method: 'PATCH',
body: data,
});
}
return apiFetch('/api/v1/expenses', { method: 'POST', body: data });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['expenses'] });
onOpenChange(false);
},
});
function onSubmit(data: CreateExpenseInput) {
mutation.mutate(data);
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Expense' : 'New Expense'}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="expenseDate">Date *</Label>
<Input
id="expenseDate"
type="date"
{...register('expenseDate', {
setValueAs: (v) => (v ? new Date(v) : undefined),
})}
defaultValue={expense?.expenseDate
? new Date(expense.expenseDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0]}
/>
{errors.expenseDate && (
<p className="text-xs text-destructive">{errors.expenseDate.message}</p>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="amount">Amount *</Label>
<Input
id="amount"
type="number"
step="0.01"
min="0"
placeholder="0.00"
{...register('amount', { valueAsNumber: true })}
/>
{errors.amount && (
<p className="text-xs text-destructive">{errors.amount.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
placeholder="USD"
maxLength={3}
{...register('currency')}
/>
{errors.currency && (
<p className="text-xs text-destructive">{errors.currency.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="establishmentName">Establishment</Label>
<Input
id="establishmentName"
placeholder="Establishment name"
{...register('establishmentName')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<Select
onValueChange={(v) => setValue('category', v as any)}
defaultValue={expense?.category ?? undefined}
>
<SelectTrigger id="category">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{EXPENSE_CATEGORIES.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="paymentMethod">Payment Method</Label>
<Select
onValueChange={(v) => setValue('paymentMethod', v as any)}
defaultValue={expense?.paymentMethod ?? undefined}
>
<SelectTrigger id="paymentMethod">
<SelectValue placeholder="Select method" />
</SelectTrigger>
<SelectContent>
{PAYMENT_METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="payer">Payer</Label>
<Input
id="payer"
placeholder="Who paid?"
{...register('payer')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="paymentStatus">Payment Status</Label>
<Select
onValueChange={(v) => setValue('paymentStatus', v as any)}
defaultValue={expense?.paymentStatus ?? 'unpaid'}
>
<SelectTrigger id="paymentStatus">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unpaid">Unpaid</SelectItem>
<SelectItem value="paid">Paid</SelectItem>
<SelectItem value="partial">Partial</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Additional notes..."
rows={3}
{...register('description')}
/>
</div>
{mutation.isError && (
<p className="text-sm text-destructive">
{(mutation.error as Error).message}
</p>
)}
<SheetFooter className="pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Expense'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import { Download, Eye, FileText, Film, Image, MoreHorizontal, Pencil, Sheet, Trash2 } from 'lucide-react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { PREVIEWABLE_MIMES } from '@/lib/constants/file-validation';
export interface FileRow {
id: string;
filename: string;
originalName: string;
mimeType: string | null;
sizeBytes: string | null;
storagePath?: string;
category: string | null;
createdAt: string | Date;
uploadedBy: string;
}
interface FileGridProps {
files: FileRow[];
onDownload: (file: FileRow) => void;
onPreview: (file: FileRow) => void;
onRename: (file: FileRow) => void;
onDelete: (file: FileRow) => void;
isLoading?: boolean;
}
function formatBytes(bytes: string | null): string {
if (!bytes) return '';
const n = Number(bytes);
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function FileIcon({ mimeType }: { mimeType: string | null }) {
if (!mimeType) return <FileText className="h-8 w-8 text-muted-foreground" />;
if (mimeType.startsWith('image/')) return <Image className="h-8 w-8 text-blue-500" />;
if (mimeType === 'application/pdf') return <FileText className="h-8 w-8 text-red-500" />;
if (
mimeType === 'application/vnd.ms-excel' ||
mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
mimeType === 'text/csv'
) {
return <Sheet className="h-8 w-8 text-green-600" />;
}
if (mimeType.startsWith('video/')) return <Film className="h-8 w-8 text-purple-500" />;
return <FileText className="h-8 w-8 text-muted-foreground" />;
}
export function FileGrid({
files,
onDownload,
onPreview,
onRename,
onDelete,
isLoading,
}: FileGridProps) {
if (isLoading) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-32 rounded-lg" />
))}
</div>
);
}
if (files.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center text-muted-foreground">
<FileText className="h-10 w-10 mb-3 opacity-40" />
<p className="text-sm font-medium">No files yet</p>
<p className="text-xs mt-1">Upload files using the zone above</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{files.map((file) => (
<div
key={file.id}
className="group relative rounded-lg border bg-card p-3 hover:border-primary/50 hover:shadow-sm transition-all"
>
<div className="flex flex-col items-center gap-2">
<FileIcon mimeType={file.mimeType} />
<p className="w-full truncate text-center text-xs font-medium" title={file.filename}>
{file.filename}
</p>
<div className="flex flex-col items-center gap-0.5 text-[10px] text-muted-foreground">
<span>{formatBytes(file.sizeBytes)}</span>
<span>{format(new Date(file.createdAt), 'MMM d, yyyy')}</span>
</div>
</div>
<div className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 transition-opacity">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => onDownload(file)}>
<Download className="mr-2 h-3.5 w-3.5" />
Download
</DropdownMenuItem>
{file.mimeType && PREVIEWABLE_MIMES.has(file.mimeType) && (
<DropdownMenuItem onClick={() => onPreview(file)}>
<Eye className="mr-2 h-3.5 w-3.5" />
Preview
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onRename(file)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Rename
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(file)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { ExternalLink } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { apiFetch } from '@/lib/api/client';
interface FilePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileId?: string;
fileName?: string;
mimeType?: string;
}
export function FilePreviewDialog({
open,
onOpenChange,
fileId,
fileName,
mimeType,
}: FilePreviewDialogProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open || !fileId) {
setPreviewUrl(null);
setError(null);
return;
}
setLoading(true);
setError(null);
apiFetch<{ data: { url: string } }>(`/api/v1/files/${fileId}/preview`)
.then((res) => {
setPreviewUrl(res.data.url);
})
.catch(() => {
setError('Failed to load preview');
})
.finally(() => {
setLoading(false);
});
}, [open, fileId]);
const isImage = mimeType?.startsWith('image/');
const isPdf = mimeType === 'application/pdf';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-full h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 truncate">
<span className="truncate">{fileName ?? 'Preview'}</span>
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-hidden rounded-lg border bg-muted/20">
{loading && (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading preview...
</div>
)}
{error && (
<div className="flex h-full items-center justify-center text-sm text-destructive">
{error}
</div>
)}
{!loading && !error && previewUrl && isImage && (
<div className="flex h-full items-center justify-center p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewUrl}
alt={fileName ?? 'Preview'}
className="max-h-full max-w-full object-contain rounded"
/>
</div>
)}
{!loading && !error && previewUrl && isPdf && (
<iframe
src={previewUrl}
title={fileName ?? 'PDF Preview'}
className="h-full w-full"
/>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { Upload, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface UploadingFile {
id: string;
name: string;
progress: number;
error?: string;
}
interface FileUploadZoneProps {
entityType?: string;
entityId?: string;
clientId?: string;
onUploadComplete?: () => void;
}
export function FileUploadZone({
entityType,
entityId,
clientId,
onUploadComplete,
}: FileUploadZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState<UploadingFile[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const uploadFiles = useCallback(
async (fileList: FileList) => {
const newUploads: UploadingFile[] = Array.from(fileList).map((f) => ({
id: crypto.randomUUID(),
name: f.name,
progress: 0,
}));
setUploading((prev) => [...prev, ...newUploads]);
await Promise.all(
Array.from(fileList).map(async (file, i) => {
const uploadId = newUploads[i]!.id;
try {
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
if (clientId) formData.append('clientId', clientId);
if (entityType) formData.append('entityType', entityType);
if (entityId) formData.append('entityId', entityId);
setUploading((prev) =>
prev.map((u) => (u.id === uploadId ? { ...u, progress: 50 } : u)),
);
// Use fetch directly for FormData (apiFetch JSON-encodes body)
const portId = (await import('@/stores/ui-store'))
.useUIStore.getState().currentPortId;
const headers = new Headers();
if (portId) headers.set('X-Port-Id', portId);
const uploadRes = await fetch('/api/v1/files/upload', {
method: 'POST',
headers,
credentials: 'include',
body: formData,
});
if (!uploadRes.ok) {
throw new Error('Upload failed');
}
setUploading((prev) =>
prev.map((u) => (u.id === uploadId ? { ...u, progress: 100 } : u)),
);
} catch {
setUploading((prev) =>
prev.map((u) =>
u.id === uploadId ? { ...u, error: 'Upload failed' } : u,
),
);
}
}),
);
// Clear completed uploads after a moment
setTimeout(() => {
setUploading((prev) => prev.filter((u) => u.error));
onUploadComplete?.();
}, 1500);
},
[clientId, entityType, entityId, onUploadComplete],
);
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
if (e.dataTransfer.files.length > 0) {
void uploadFiles(e.dataTransfer.files);
}
},
[uploadFiles],
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
void uploadFiles(e.target.files);
e.target.value = '';
}
},
[uploadFiles],
);
return (
<div className="space-y-3">
<div
role="button"
tabIndex={0}
className={cn(
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer',
isDragOver
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
)}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
}}
>
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm font-medium">Drop files here or click to upload</p>
<p className="text-xs text-muted-foreground mt-1">
PDF, Word, Excel, images up to 50MB
</p>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={handleChange}
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv"
/>
</div>
{uploading.length > 0 && (
<div className="space-y-2">
{uploading.map((u) => (
<div key={u.id} className="flex items-center gap-3 text-sm">
<span className="flex-1 truncate">{u.name}</span>
{u.error ? (
<span className="text-destructive text-xs">{u.error}</span>
) : (
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${u.progress}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">{u.progress}%</span>
</div>
)}
{u.error && (
<button
type="button"
onClick={() =>
setUploading((prev) => prev.filter((x) => x.id !== u.id))
}
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import { useState } from 'react';
import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { FileRow } from '@/components/files/file-grid';
interface FolderNode {
name: string;
fullPath: string;
children: Record<string, FolderNode>;
}
function buildFolderTree(files: FileRow[]): FolderNode {
const root: FolderNode = { name: '', fullPath: '', children: {} };
for (const file of files) {
const parts = file.storagePath ? file.storagePath.split('/').slice(0, -1) : [];
if (parts.length <= 1) continue; // skip files directly in root/port folder
let node = root;
let accumulated = '';
for (const part of parts.slice(1)) { // skip portSlug prefix
accumulated = accumulated ? `${accumulated}/${part}` : part;
if (!node.children[part]) {
node.children[part] = { name: part, fullPath: accumulated, children: {} };
}
node = node.children[part]!;
}
}
return root;
}
interface FolderNodeComponentProps {
node: FolderNode;
currentFolder: string;
onFolderSelect: (path: string) => void;
depth?: number;
}
function FolderNodeComponent({
node,
currentFolder,
onFolderSelect,
depth = 0,
}: FolderNodeComponentProps) {
const [expanded, setExpanded] = useState(true);
const hasChildren = Object.keys(node.children).length > 0;
const isSelected = currentFolder === node.fullPath;
return (
<div>
<button
type="button"
onClick={() => {
onFolderSelect(node.fullPath);
if (hasChildren) setExpanded((v) => !v);
}}
className={cn(
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted/60 transition-colors',
isSelected && 'bg-muted font-medium',
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
>
{hasChildren ? (
expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)
) : (
<span className="w-3.5" />
)}
{isSelected ? (
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
) : (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="truncate">{node.name}</span>
</button>
{hasChildren && expanded && (
<div>
{Object.values(node.children).map((child) => (
<FolderNodeComponent
key={child.fullPath}
node={child}
currentFolder={currentFolder}
onFolderSelect={onFolderSelect}
depth={depth + 1}
/>
))}
</div>
)}
</div>
);
}
interface FolderTreeProps {
files: (FileRow & { storagePath: string })[];
currentFolder: string;
onFolderSelect: (path: string) => void;
}
export function FolderTree({ files, currentFolder, onFolderSelect }: FolderTreeProps) {
const tree = buildFolderTree(files);
return (
<div className="space-y-0.5">
<button
type="button"
onClick={() => onFolderSelect('')}
className={cn(
'flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm hover:bg-muted/60 transition-colors',
currentFolder === '' && 'bg-muted font-medium',
)}
>
<span className="w-3.5" />
{currentFolder === '' ? (
<FolderOpen className="h-4 w-4 shrink-0 text-primary" />
) : (
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span>All Files</span>
</button>
{Object.values(tree.children).map((child) => (
<FolderNodeComponent
key={child.fullPath}
node={child}
currentFolder={currentFolder}
onFolderSelect={onFolderSelect}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,220 @@
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Pencil, Archive } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
export interface InterestRow {
id: string;
clientId: string;
clientName: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
archivedAt: string | null;
createdAt: string;
tags?: Array<{ id: string; name: string; color: string }>;
}
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, string> = {
open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700',
in_communication: 'bg-sky-100 text-sky-700',
visited: 'bg-violet-100 text-violet-700',
signed_eoi_nda: 'bg-amber-100 text-amber-700',
deposit_10pct: 'bg-orange-100 text-orange-700',
contract: 'bg-green-100 text-green-700',
completed: 'bg-emerald-100 text-emerald-700',
};
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
const SOURCE_LABELS: Record<string, string> = {
website: 'Website',
manual: 'Manual',
referral: 'Referral',
broker: 'Broker',
};
interface GetColumnsOptions {
portSlug: string;
onEdit: (interest: InterestRow) => void;
onArchive: (interest: InterestRow) => void;
}
export function getInterestColumns({
portSlug,
onEdit,
onArchive,
}: GetColumnsOptions): ColumnDef<InterestRow, unknown>[] {
return [
{
id: 'clientName',
accessorKey: 'clientName',
header: 'Client',
cell: ({ row }) => (
<Link
href={`/${portSlug}/clients/${row.original.clientId}`}
className="font-medium text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{row.original.clientName ?? '—'}
</Link>
),
},
{
id: 'berthMooringNumber',
accessorKey: 'berthMooringNumber',
header: 'Berth',
cell: ({ row }) => {
if (!row.original.berthId || !row.original.berthMooringNumber) {
return <span className="text-muted-foreground"></span>;
}
return (
<Link
href={`/${portSlug}/berths/${row.original.berthId}`}
className="text-primary hover:underline text-sm"
onClick={(e) => e.stopPropagation()}
>
{row.original.berthMooringNumber}
</Link>
);
},
},
{
id: 'pipelineStage',
accessorKey: 'pipelineStage',
header: 'Stage',
cell: ({ getValue }) => {
const stage = getValue() as string;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STAGE_COLORS[stage] ?? 'bg-gray-100 text-gray-700'}`}
>
{STAGE_LABELS[stage] ?? stage}
</span>
);
},
},
{
id: 'leadCategory',
accessorKey: 'leadCategory',
header: 'Category',
cell: ({ getValue }) => {
const cat = getValue() as string | null;
if (!cat) return <span className="text-muted-foreground"></span>;
return (
<Badge variant="outline" className="text-xs capitalize">
{CATEGORY_LABELS[cat] ?? cat}
</Badge>
);
},
},
{
id: 'source',
accessorKey: 'source',
header: 'Source',
cell: ({ getValue }) => {
const source = getValue() as string | null;
if (!source) return <span className="text-muted-foreground"></span>;
return (
<Badge variant="outline" className="text-xs">
{SOURCE_LABELS[source] ?? source}
</Badge>
);
},
},
{
id: 'tags',
header: 'Tags',
enableSorting: false,
cell: ({ row }) => {
const rowTags = row.original.tags ?? [];
if (rowTags.length === 0) return <span className="text-muted-foreground"></span>;
return (
<div className="flex flex-wrap gap-1">
{rowTags.slice(0, 3).map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
{rowTags.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{rowTags.length - 3}
</Badge>
)}
</div>
);
},
},
{
id: 'createdAt',
accessorKey: 'createdAt',
header: 'Created',
cell: ({ getValue }) => (
<span className="text-muted-foreground text-sm">
{format(new Date(getValue() as string), 'MMM d, yyyy')}
</span>
),
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Pencil className="mr-2 h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive"
onClick={() => onArchive(row.original)}
>
<Archive className="mr-2 h-3.5 w-3.5" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
),
},
];
}

View File

@@ -0,0 +1,211 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Pencil, Archive, RotateCcw, TrendingUp } from 'lucide-react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { TagBadge } from '@/components/shared/tag-badge';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { InterestStagePicker } from '@/components/interests/interest-stage-picker';
import { apiFetch } from '@/lib/api/client';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const STAGE_COLORS: Record<string, string> = {
open: 'bg-slate-100 text-slate-700',
details_sent: 'bg-blue-100 text-blue-700',
in_communication: 'bg-sky-100 text-sky-700',
visited: 'bg-violet-100 text-violet-700',
signed_eoi_nda: 'bg-amber-100 text-amber-700',
deposit_10pct: 'bg-orange-100 text-orange-700',
contract: 'bg-green-100 text-green-700',
completed: 'bg-emerald-100 text-emerald-700',
};
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
interface InterestDetailHeaderProps {
portSlug: string;
interest: {
id: string;
clientId: string;
clientName: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
notes: string | null;
reminderEnabled: boolean;
reminderDays: number | null;
archivedAt: string | null;
tags?: Array<{ id: string; name: string; color: string }>;
};
}
export function InterestDetailHeader({ portSlug, interest }: InterestDetailHeaderProps) {
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [archiveOpen, setArchiveOpen] = useState(false);
const [stageOpen, setStageOpen] = useState(false);
const isArchived = !!interest.archivedAt;
const archiveMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
const restoreMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interest.id}/restore`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interest.id] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveOpen(false);
},
});
return (
<>
<div className="space-y-3">
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold text-foreground">
{interest.clientName ?? 'Unknown Client'}
</h1>
{isArchived && (
<Badge variant="secondary" className="text-xs">Archived</Badge>
)}
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-sm font-medium ${STAGE_COLORS[interest.pipelineStage] ?? 'bg-gray-100 text-gray-700'}`}
>
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
</span>
</div>
<div className="flex items-center gap-4 mt-2 flex-wrap text-sm text-muted-foreground">
{interest.berthMooringNumber && (
<span>
Berth:{' '}
<Link
href={`/${portSlug}/berths/${interest.berthId}`}
className="text-foreground hover:underline"
>
{interest.berthMooringNumber}
</Link>
</span>
)}
{interest.leadCategory && (
<span>
Category:{' '}
<span className="text-foreground">
{CATEGORY_LABELS[interest.leadCategory] ?? interest.leadCategory}
</span>
</span>
)}
{interest.source && (
<span>
Source:{' '}
<span className="text-foreground capitalize">{interest.source}</span>
</span>
)}
</div>
{interest.tags && interest.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{interest.tags.map((tag) => (
<TagBadge key={tag.id} name={tag.name} color={tag.color} />
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-wrap">
<PermissionGate resource="interests" action="edit">
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Pencil className="mr-1.5 h-3.5 w-3.5" />
Edit
</Button>
</PermissionGate>
<PermissionGate resource="interests" action="change_stage">
<Button variant="outline" size="sm" onClick={() => setStageOpen(true)}>
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
Change Stage
</Button>
</PermissionGate>
<PermissionGate resource="interests" action="delete">
<Button variant="outline" size="sm" onClick={() => setArchiveOpen(true)}>
{isArchived ? (
<>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
Restore
</>
) : (
<>
<Archive className="mr-1.5 h-3.5 w-3.5" />
Archive
</>
)}
</Button>
</PermissionGate>
</div>
</div>
</div>
<InterestForm
open={editOpen}
onOpenChange={setEditOpen}
interest={interest as any}
/>
<InterestStagePicker
open={stageOpen}
onOpenChange={setStageOpen}
interestId={interest.id}
currentStage={interest.pipelineStage}
/>
<ArchiveConfirmDialog
open={archiveOpen}
onOpenChange={setArchiveOpen}
entityName={interest.clientName ?? 'Interest'}
entityType="Interest"
isArchived={isArchived}
onConfirm={() => {
if (isArchived) {
restoreMutation.mutate();
} else {
archiveMutation.mutate();
}
}}
isLoading={archiveMutation.isPending || restoreMutation.isPending}
/>
</>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { DetailLayout } from '@/components/shared/detail-layout';
import { InterestDetailHeader } from '@/components/interests/interest-detail-header';
import { getInterestTabs } from '@/components/interests/interest-tabs';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
interface InterestData {
id: string;
portId: string;
clientId: string;
clientName: string | null;
berthId: string | null;
berthMooringNumber: string | null;
pipelineStage: string;
leadCategory: string | null;
source: string | null;
eoiStatus: string | null;
contractStatus: string | null;
depositStatus: string | null;
reservationStatus: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
dateDepositReceived: string | null;
reminderEnabled: boolean;
reminderDays: number | null;
reminderLastFired: string | null;
notes: string | null;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
tags: Array<{ id: string; name: string; color: string }>;
}
interface InterestDetailProps {
interestId: string;
currentUserId?: string;
}
export function InterestDetail({ interestId, currentUserId }: InterestDetailProps) {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const { data, isLoading } = useQuery<InterestData>({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`).then(
(r) => r.data,
),
});
useRealtimeInvalidation({
'interest:updated': [['interests', interestId]],
'interest:stageChanged': [['interests', interestId]],
'interest:archived': [['interests', interestId]],
'interest:berthLinked': [['interests', interestId]],
'interest:berthUnlinked': [['interests', interestId]],
});
const tabs = data
? getInterestTabs({ interestId, currentUserId, interest: data })
: [];
return (
<DetailLayout
header={
data ? (
<InterestDetailHeader portSlug={portSlug} interest={data} />
) : null
}
tabs={tabs}
defaultTab="overview"
isLoading={isLoading}
/>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DocumentList } from '@/components/documents/document-list';
import { EoiGenerateDialog } from '@/components/documents/eoi-generate-dialog';
import { apiFetch } from '@/lib/api/client';
interface InterestDocumentsTabProps {
interestId: string;
}
interface InterestData {
id: string;
berthId?: string | null;
client?: {
fullName?: string | null;
yachtLengthFt?: string | null;
yachtLengthM?: string | null;
contacts?: Array<{ channel: string; value: string }>;
};
}
export function InterestDocumentsTab({ interestId }: InterestDocumentsTabProps) {
const [eoiDialogOpen, setEoiDialogOpen] = useState(false);
const queryClient = useQueryClient();
const { data: interestRes } = useQuery({
queryKey: ['interests', interestId],
queryFn: () =>
apiFetch<{ data: InterestData }>(`/api/v1/interests/${interestId}`),
});
const interest = interestRes?.data;
const prerequisites = {
hasName: Boolean(interest?.client?.fullName),
hasEmail: Boolean(
interest?.client?.contacts?.some((c) => c.channel === 'email' && c.value),
),
hasYachtDims: Boolean(
interest?.client?.yachtLengthFt || interest?.client?.yachtLengthM,
),
hasBerth: Boolean(interest?.berthId),
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Documents</h3>
<Button size="sm" onClick={() => setEoiDialogOpen(true)}>
Generate EOI
</Button>
</div>
<DocumentList interestId={interestId} />
<EoiGenerateDialog
interestId={interestId}
open={eoiDialogOpen}
onOpenChange={setEoiDialogOpen}
prerequisites={prerequisites}
/>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { FileGrid } from '@/components/files/file-grid';
import { FileUploadZone } from '@/components/files/file-upload-zone';
import { FilePreviewDialog } from '@/components/files/file-preview-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import type { FileRow } from '@/components/files/file-grid';
interface InterestFilesTabProps {
interestId: string;
}
export function InterestFilesTab({ interestId }: InterestFilesTabProps) {
const queryClient = useQueryClient();
const [previewFile, setPreviewFile] = useState<FileRow | null>(null);
const { data, isLoading } = usePaginatedQuery<FileRow>({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
endpoint: `/api/v1/files?entityType=interest&entityId=${encodeURIComponent(interestId)}`,
filterDefinitions: [],
});
useRealtimeInvalidation({
'file:uploaded': [['files', { entityType: 'interest', entityId: interestId }]],
'file:updated': [['files', { entityType: 'interest', entityId: interestId }]],
'file:deleted': [['files', { entityType: 'interest', entityId: interestId }]],
});
const handleDownload = async (file: FileRow) => {
try {
const res = await apiFetch<{ data: { url: string; filename: string } }>(
`/api/v1/files/${file.id}/download`,
);
const a = document.createElement('a');
a.href = res.data.url;
a.download = res.data.filename;
a.click();
} catch {
// silent
}
};
const handleDelete = async (file: FileRow) => {
if (!confirm(`Delete "${file.filename}"? This cannot be undone.`)) return;
try {
await apiFetch(`/api/v1/files/${file.id}`, { method: 'DELETE' });
queryClient.invalidateQueries({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
});
} catch {
// silent
}
};
return (
<div className="space-y-4">
<PermissionGate resource="files" action="upload">
<FileUploadZone
entityType="interest"
entityId={interestId}
onUploadComplete={() => {
queryClient.invalidateQueries({
queryKey: ['files', { entityType: 'interest', entityId: interestId }],
});
}}
/>
</PermissionGate>
<FileGrid
files={data}
onDownload={handleDownload}
onPreview={setPreviewFile}
onRename={() => {}}
onDelete={handleDelete}
isLoading={isLoading}
/>
<FilePreviewDialog
open={!!previewFile}
onOpenChange={(open) => !open && setPreviewFile(null)}
fileId={previewFile?.id}
fileName={previewFile?.filename}
mimeType={previewFile?.mimeType ?? undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
export const interestFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search interests...',
},
{
key: 'pipelineStage',
label: 'Stage',
type: 'multi-select',
options: PIPELINE_STAGES.map((s) => ({
label: STAGE_LABELS[s] ?? s,
value: s,
})),
},
{
key: 'leadCategory',
label: 'Category',
type: 'select',
options: LEAD_CATEGORIES.map((c) => ({
label: CATEGORY_LABELS[c] ?? c,
value: c,
})),
},
{
key: 'source',
label: 'Source',
type: 'select',
options: [
{ label: 'Website', value: 'website' },
{ label: 'Manual', value: 'manual' },
{ label: 'Referral', value: 'referral' },
{ label: 'Broker', value: 'broker' },
],
},
{
key: 'eoiStatus',
label: 'EOI Status',
type: 'select',
options: [
{ label: 'Waiting for Signatures', value: 'waiting_for_signatures' },
{ label: 'Signed', value: 'signed' },
{ label: 'Expired', value: 'expired' },
],
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,455 @@
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, ChevronsUpDown, Check } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
} from '@/components/ui/sheet';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Checkbox } from '@/components/ui/checkbox';
import { Separator } from '@/components/ui/separator';
import { TagPicker } from '@/components/shared/tag-picker';
import { apiFetch } from '@/lib/api/client';
import { useEntityOptions } from '@/hooks/use-entity-options';
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
import { PIPELINE_STAGES, LEAD_CATEGORIES } from '@/lib/constants';
import { cn } from '@/lib/utils';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
const CATEGORY_LABELS: Record<string, string> = {
general_interest: 'General Interest',
specific_qualified: 'Specific Qualified',
hot_lead: 'Hot Lead',
};
interface InterestFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interest?: {
id: string;
clientId: string;
clientName?: string | null;
berthId?: string | null;
berthMooringNumber?: string | null;
pipelineStage: string;
leadCategory?: string | null;
source?: string | null;
notes?: string | null;
reminderEnabled?: boolean;
reminderDays?: number | null;
tags?: Array<{ id: string }>;
};
}
export function InterestForm({ open, onOpenChange, interest }: InterestFormProps) {
const queryClient = useQueryClient();
const isEdit = !!interest;
const [clientOpen, setClientOpen] = useState(false);
const [berthOpen, setBerthOpen] = useState(false);
const {
register,
handleSubmit,
watch,
setValue,
reset,
formState: { errors, isSubmitting },
} = useForm<CreateInterestInput>({
resolver: zodResolver(createInterestSchema),
defaultValues: {
clientId: '',
pipelineStage: 'open',
reminderEnabled: false,
tagIds: [],
},
});
const tagIds = watch('tagIds') ?? [];
const reminderEnabled = watch('reminderEnabled');
const selectedClientId = watch('clientId');
const selectedBerthId = watch('berthId');
const { options: clientOptions, isLoading: clientsLoading, setSearch: setClientSearch } =
useEntityOptions({
endpoint: '/api/v1/clients/options',
labelKey: 'fullName',
});
const { options: berthOptions, isLoading: berthsLoading, setSearch: setBerthSearch } =
useEntityOptions({
endpoint: '/api/v1/berths/options',
labelKey: 'mooringNumber',
});
useEffect(() => {
if (interest && open) {
reset({
clientId: interest.clientId,
berthId: interest.berthId ?? undefined,
pipelineStage: interest.pipelineStage as typeof PIPELINE_STAGES[number],
leadCategory: interest.leadCategory as typeof LEAD_CATEGORIES[number] | undefined,
source: interest.source ?? undefined,
notes: interest.notes ?? undefined,
reminderEnabled: interest.reminderEnabled ?? false,
reminderDays: interest.reminderDays ?? undefined,
tagIds: interest.tags?.map((t) => t.id) ?? [],
});
} else if (!interest && open) {
reset({
clientId: '',
pipelineStage: 'open',
reminderEnabled: false,
tagIds: [],
});
}
}, [interest, open, reset]);
const mutation = useMutation({
mutationFn: async (data: CreateInterestInput) => {
if (isEdit) {
const { tagIds: tIds, ...rest } = data;
await apiFetch(`/api/v1/interests/${interest!.id}`, { method: 'PATCH', body: rest });
if (tIds) {
await apiFetch(`/api/v1/interests/${interest!.id}/tags`, {
method: 'PUT',
body: { tagIds: tIds },
});
}
} else {
await apiFetch('/api/v1/interests', { method: 'POST', body: data });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false);
},
});
const selectedClient = clientOptions.find((c) => c.value === selectedClientId);
const selectedBerth = berthOptions.find((b) => b.value === selectedBerthId);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
<SheetHeader>
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
</SheetHeader>
<form
onSubmit={handleSubmit((data) => mutation.mutate(data))}
className="space-y-6 py-6"
>
{/* Client */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Client & Berth
</h3>
<div className="space-y-1">
<Label>Client *</Label>
<Popover open={clientOpen} onOpenChange={setClientOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={clientOpen}
className={cn(
'w-full justify-between',
!selectedClientId && 'text-muted-foreground',
)}
disabled={isEdit}
>
{selectedClient?.label ?? (interest?.clientName ?? 'Select client...')}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput
placeholder="Search clients..."
onValueChange={setClientSearch}
/>
<CommandList>
<CommandEmpty>
{clientsLoading ? 'Loading...' : 'No clients found.'}
</CommandEmpty>
<CommandGroup>
{clientOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('clientId', val);
setClientOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedClientId === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{errors.clientId && (
<p className="text-xs text-destructive">{errors.clientId.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Berth (optional)</Label>
<Popover open={berthOpen} onOpenChange={setBerthOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={berthOpen}
className={cn(
'w-full justify-between',
!selectedBerthId && 'text-muted-foreground',
)}
>
{selectedBerth?.label ?? (interest?.berthMooringNumber ?? 'Select berth...')}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput
placeholder="Search berths..."
onValueChange={setBerthSearch}
/>
<CommandList>
<CommandEmpty>
{berthsLoading ? 'Loading...' : 'No berths found.'}
</CommandEmpty>
<CommandGroup>
<CommandItem
value=""
onSelect={() => {
setValue('berthId', undefined);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
!selectedBerthId ? 'opacity-100' : 'opacity-0',
)}
/>
None
</CommandItem>
{berthOptions.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={(val) => {
setValue('berthId', val);
setBerthOpen(false);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedBerthId === option.value ? 'opacity-100' : 'opacity-0',
)}
/>
{option.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<Separator />
{/* Pipeline */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Pipeline
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Stage</Label>
<Select
value={watch('pipelineStage') ?? 'open'}
onValueChange={(v) => setValue('pipelineStage', v as typeof PIPELINE_STAGES[number])}
>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
{PIPELINE_STAGES.map((s) => (
<SelectItem key={s} value={s}>
{STAGE_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Lead Category</Label>
<Select
value={watch('leadCategory') ?? ''}
onValueChange={(v) =>
setValue('leadCategory', v ? v as typeof LEAD_CATEGORIES[number] : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
{LEAD_CATEGORIES.map((c) => (
<SelectItem key={c} value={c}>
{CATEGORY_LABELS[c] ?? c}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Source</Label>
<Select
value={watch('source') ?? ''}
onValueChange={(v) => setValue('source', v || undefined)}
>
<SelectTrigger>
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="referral">Referral</SelectItem>
<SelectItem value="broker">Broker</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Separator />
{/* Notes */}
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
{...register('notes')}
placeholder="Add notes about this interest..."
rows={3}
/>
</div>
<Separator />
{/* Reminder */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Reminder
</h3>
<div className="flex items-center gap-2">
<Checkbox
id="reminderEnabled"
checked={reminderEnabled ?? false}
onCheckedChange={(v) => setValue('reminderEnabled', !!v)}
/>
<Label htmlFor="reminderEnabled">Enable reminder</Label>
</div>
{reminderEnabled && (
<div className="space-y-1">
<Label>Reminder Days</Label>
<Input
{...register('reminderDays', { valueAsNumber: true })}
type="number"
min={1}
placeholder="e.g. 7"
/>
</div>
)}
</div>
<Separator />
{/* Tags */}
<div className="space-y-2">
<Label>Tags</Label>
<TagPicker
selectedIds={tagIds}
onChange={(ids) => setValue('tagIds', ids)}
/>
</div>
<SheetFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
{(isSubmitting || mutation.isPending) && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{isEdit ? 'Save Changes' : 'Create Interest'}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { Plus, LayoutList, Kanban } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { DataTable } from '@/components/shared/data-table';
import { FilterBar } from '@/components/shared/filter-bar';
import { SavedViewsDropdown } from '@/components/shared/saved-views-dropdown';
import { PageHeader } from '@/components/shared/page-header';
import { EmptyState } from '@/components/shared/empty-state';
import { TableSkeleton } from '@/components/shared/loading-skeleton';
import { ArchiveConfirmDialog } from '@/components/shared/archive-confirm-dialog';
import { PermissionGate } from '@/components/shared/permission-gate';
import { InterestForm } from '@/components/interests/interest-form';
import { PipelineBoard } from '@/components/interests/pipeline-board';
import { interestFilterDefinitions } from '@/components/interests/interest-filters';
import { getInterestColumns, type InterestRow } from '@/components/interests/interest-columns';
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { apiFetch } from '@/lib/api/client';
import { usePipelineStore } from '@/stores/pipeline-store';
export function InterestList() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { viewMode, setViewMode } = usePipelineStore();
const [createOpen, setCreateOpen] = useState(false);
const [editInterest, setEditInterest] = useState<InterestRow | null>(null);
const [archiveInterest, setArchiveInterest] = useState<InterestRow | null>(null);
const {
data,
pagination,
isLoading,
isFetching,
sort,
setSort,
setPage,
setPageSize,
filters,
setFilter,
clearFilters,
} = usePaginatedQuery<InterestRow>({
queryKey: ['interests'],
endpoint: '/api/v1/interests',
filterDefinitions: interestFilterDefinitions,
});
useRealtimeInvalidation({
'interest:created': [['interests']],
'interest:updated': [['interests']],
'interest:stageChanged': [['interests']],
'interest:archived': [['interests']],
'interest:berthLinked': [['interests']],
'interest:berthUnlinked': [['interests']],
});
const archiveMutation = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/v1/interests/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests'] });
setArchiveInterest(null);
},
});
const columns = getInterestColumns({
portSlug,
onEdit: (interest) => setEditInterest(interest),
onArchive: (interest) => setArchiveInterest(interest),
});
return (
<div className="space-y-4">
<PageHeader
title="Interests"
description="Track prospective berth interest and pipeline"
actions={
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden">
<Button
size="sm"
variant={viewMode === 'table' ? 'default' : 'ghost'}
className="rounded-none"
onClick={() => setViewMode('table')}
>
<LayoutList className="h-4 w-4" />
</Button>
<Button
size="sm"
variant={viewMode === 'board' ? 'default' : 'ghost'}
className="rounded-none"
onClick={() => setViewMode('board')}
>
<Kanban className="h-4 w-4" />
</Button>
</div>
<PermissionGate resource="interests" action="create">
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-4 w-4" />
New Interest
</Button>
</PermissionGate>
</div>
}
/>
<div className="flex items-center gap-2">
<FilterBar
filters={interestFilterDefinitions}
values={filters}
onChange={setFilter}
onClear={clearFilters}
/>
<SavedViewsDropdown
entityType="interests"
currentFilters={filters}
currentSort={sort}
onApplyView={(savedFilters) => {
clearFilters();
Object.entries(savedFilters).forEach(([key, val]) => setFilter(key, val));
}}
/>
</div>
{viewMode === 'board' ? (
<PipelineBoard />
) : isLoading ? (
<TableSkeleton />
) : (
<DataTable
columns={columns}
data={data}
pagination={pagination}
onPaginationChange={(p, ps) => {
setPage(p);
setPageSize(ps);
}}
sort={sort}
onSortChange={setSort}
isLoading={isFetching && !isLoading}
getRowId={(row) => row.id}
emptyState={
<EmptyState
title="No interests found"
description="Get started by adding your first interest."
action={{ label: 'New Interest', onClick: () => setCreateOpen(true) }}
/>
}
/>
)}
<InterestForm
open={createOpen}
onOpenChange={setCreateOpen}
/>
{editInterest && (
<InterestForm
open={!!editInterest}
onOpenChange={(open) => !open && setEditInterest(null)}
interest={editInterest as any}
/>
)}
<ArchiveConfirmDialog
open={!!archiveInterest}
onOpenChange={(open) => !open && setArchiveInterest(null)}
entityName={archiveInterest?.clientName ?? 'Interest'}
entityType="Interest"
isArchived={false}
onConfirm={() =>
archiveInterest && archiveMutation.mutate(archiveInterest.id)
}
isLoading={archiveMutation.isPending}
/>
</div>
);
}

View File

@@ -0,0 +1,84 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFeatureFlag } from '@/hooks/use-feature-flag';
import { apiFetch } from '@/lib/api/client';
import type { InterestScore } from '@/lib/services/interest-scoring.service';
// ─── Score tier helpers ───────────────────────────────────────────────────────
function getScoreTier(score: number): { label: string; className: string } {
if (score >= 80) return { label: 'Hot', className: 'bg-green-100 text-green-800 border-green-200' };
if (score >= 60) return { label: 'Warm', className: 'bg-yellow-100 text-yellow-800 border-yellow-200' };
if (score >= 40) return { label: 'Cool', className: 'bg-orange-100 text-orange-800 border-orange-200' };
return { label: 'Cold', className: 'bg-gray-100 text-gray-700 border-gray-200' };
}
// ─── Component ────────────────────────────────────────────────────────────────
interface InterestScoreBadgeProps {
interestId: string;
}
export function InterestScoreBadge({ interestId }: InterestScoreBadgeProps) {
const featureEnabled = useFeatureFlag('ai_interest_scoring');
const { data, isLoading } = useQuery<{ data: InterestScore }>({
queryKey: ['interest-score', interestId],
queryFn: () => apiFetch(`/api/v1/ai/interest-score?interestId=${interestId}`),
enabled: featureEnabled,
staleTime: 60 * 60 * 1000, // 1 hour — mirrors server-side cache TTL
});
if (!featureEnabled) return null;
if (isLoading || !data) return null;
const score = data.data;
const { label, className } = getScoreTier(score.totalScore);
const { breakdown } = score;
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold cursor-default select-none ${className}`}
>
{label}
<span className="opacity-70">{score.totalScore}</span>
</span>
</TooltipTrigger>
<TooltipContent className="p-3 space-y-1 text-left min-w-[180px]">
<p className="font-semibold text-sm mb-2">Interest Score Breakdown</p>
<ScoreRow label="Pipeline Age" value={breakdown.pipelineAge} max={100} />
<ScoreRow label="Stage Speed" value={breakdown.stageSpeed} max={100} />
<ScoreRow label="Documents" value={breakdown.documentCompleteness} max={100} />
<ScoreRow label="Engagement" value={breakdown.engagement} max={100} />
<ScoreRow label="Berth Linked" value={breakdown.berthLinked} max={25} />
<p className="text-xs opacity-60 pt-1 border-t border-primary-foreground/20">
Total: {score.totalScore}/100
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
function ScoreRow({ label, value, max }: { label: string; value: number; max: number }) {
return (
<div className="flex items-center justify-between gap-4 text-xs">
<span>{label}</span>
<span className="font-medium">
{value}/{max}
</span>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
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';
import { PIPELINE_STAGES } from '@/lib/constants';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
interface InterestStagePickerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
interestId: string;
currentStage: string;
}
export function InterestStagePicker({
open,
onOpenChange,
interestId,
currentStage,
}: InterestStagePickerProps) {
const queryClient = useQueryClient();
const [newStage, setNewStage] = useState<string>(currentStage);
const [reason, setReason] = useState('');
const mutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: { pipelineStage: newStage, reason: reason || undefined },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
queryClient.invalidateQueries({ queryKey: ['interests'] });
onOpenChange(false);
setReason('');
},
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Stage</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Current Stage</Label>
<p className="text-sm text-muted-foreground">
{STAGE_LABELS[currentStage] ?? currentStage}
</p>
</div>
<div className="space-y-1">
<Label>New Stage</Label>
<Select value={newStage} onValueChange={setNewStage}>
<SelectTrigger>
<SelectValue placeholder="Select new stage" />
</SelectTrigger>
<SelectContent>
{PIPELINE_STAGES.map((s) => (
<SelectItem key={s} value={s}>
{STAGE_LABELS[s] ?? s}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Reason (optional)</Label>
<Textarea
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="Reason for stage change..."
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || newStage === currentStage}
>
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Confirm
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,156 @@
'use client';
import { format } from 'date-fns';
import type { DetailTab } from '@/components/shared/detail-layout';
import { NotesList } from '@/components/shared/notes-list';
import { RecommendationList } from '@/components/interests/recommendation-list';
import { InterestTimeline } from '@/components/interests/interest-timeline';
interface InterestTabsOptions {
interestId: string;
currentUserId?: string;
interest: {
eoiStatus: string | null;
contractStatus: string | null;
depositStatus: string | null;
reservationStatus: string | null;
dateFirstContact: string | null;
dateLastContact: string | null;
dateEoiSent: string | null;
dateEoiSigned: string | null;
dateContractSent: string | null;
dateContractSigned: string | null;
dateDepositReceived: string | null;
reminderEnabled: boolean;
reminderDays: number | null;
reminderLastFired: string | null;
notes: string | null;
};
}
function InfoRow({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div className="flex gap-2 py-1.5 border-b last:border-0">
<dt className="w-44 shrink-0 text-sm text-muted-foreground">{label}</dt>
<dd className="text-sm">{value}</dd>
</div>
);
}
function formatDate(date: string | null) {
if (!date) return null;
return format(new Date(date), 'MMM d, yyyy');
}
function OverviewTab({ interest }: { interest: InterestTabsOptions['interest'] }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* EOI & Contract Status */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Status</h3>
<dl>
<InfoRow label="EOI Status" value={interest.eoiStatus} />
<InfoRow label="Contract Status" value={interest.contractStatus} />
<InfoRow label="Deposit Status" value={interest.depositStatus} />
<InfoRow label="Reservation Status" value={interest.reservationStatus} />
</dl>
</div>
{/* Key Dates */}
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Key Dates</h3>
<dl>
<InfoRow label="First Contact" value={formatDate(interest.dateFirstContact)} />
<InfoRow label="Last Contact" value={formatDate(interest.dateLastContact)} />
<InfoRow label="EOI Sent" value={formatDate(interest.dateEoiSent)} />
<InfoRow label="EOI Signed" value={formatDate(interest.dateEoiSigned)} />
<InfoRow label="Contract Sent" value={formatDate(interest.dateContractSent)} />
<InfoRow label="Contract Signed" value={formatDate(interest.dateContractSigned)} />
<InfoRow label="Deposit Received" value={formatDate(interest.dateDepositReceived)} />
</dl>
</div>
{/* Reminder */}
{interest.reminderEnabled && (
<div className="space-y-1">
<h3 className="text-sm font-medium mb-2">Reminder</h3>
<dl>
<InfoRow
label="Reminder Days"
value={interest.reminderDays ? `${interest.reminderDays} days` : null}
/>
<InfoRow
label="Last Fired"
value={formatDate(interest.reminderLastFired)}
/>
</dl>
</div>
)}
{/* Notes */}
{interest.notes && (
<div className="space-y-1 md:col-span-2">
<h3 className="text-sm font-medium mb-2">Notes</h3>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{interest.notes}
</p>
</div>
)}
</div>
);
}
export function getInterestTabs({
interestId,
currentUserId,
interest,
}: InterestTabsOptions): DetailTab[] {
return [
{
id: 'overview',
label: 'Overview',
content: <OverviewTab interest={interest} />,
},
{
id: 'notes',
label: 'Notes',
content: (
<NotesList
entityType="interests"
entityId={interestId}
currentUserId={currentUserId}
/>
),
},
{
id: 'documents',
label: 'Documents',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Documents tab available after document system is built</p>
</div>
),
},
{
id: 'files',
label: 'Files',
content: (
<div className="text-center py-12 text-muted-foreground">
<p>Files tab available after file system is built</p>
</div>
),
},
{
id: 'recommendations',
label: 'Recommendations',
content: <RecommendationList interestId={interestId} />,
},
{
id: 'activity',
label: 'Activity',
content: <InterestTimeline interestId={interestId} />,
},
];
}

View File

@@ -0,0 +1,87 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Pencil, FileText, Clock, PlusCircle, Archive, RotateCcw } from 'lucide-react';
import { apiFetch } from '@/lib/api/client';
interface TimelineEvent {
id: string;
type: 'audit' | 'document_event';
action: string;
description: string;
userId: string | null;
createdAt: string;
metadata: Record<string, unknown>;
}
interface InterestTimelineProps {
interestId: string;
}
function eventIcon(event: TimelineEvent) {
if (event.type === 'document_event') return <FileText className="h-4 w-4" />;
if (event.action === 'create') return <PlusCircle className="h-4 w-4 text-green-500" />;
if (event.action === 'archive') return <Archive className="h-4 w-4 text-orange-500" />;
if (event.action === 'restore') return <RotateCcw className="h-4 w-4 text-blue-500" />;
if (event.metadata?.type === 'stage_change') return <Clock className="h-4 w-4 text-purple-500" />;
return <Pencil className="h-4 w-4 text-muted-foreground" />;
}
export function InterestTimeline({ interestId }: InterestTimelineProps) {
const { data, isLoading } = useQuery<{ data: TimelineEvent[] }>({
queryKey: ['interest-timeline', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/timeline`),
});
if (isLoading) {
return (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex gap-3 animate-pulse">
<div className="h-8 w-8 rounded-full bg-muted shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-3 bg-muted rounded w-3/4" />
<div className="h-2 bg-muted rounded w-1/2" />
</div>
</div>
))}
</div>
);
}
const events = data?.data ?? [];
if (events.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<p>No activity yet.</p>
</div>
);
}
return (
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-4 top-2 bottom-2 w-px bg-border" />
{events.map((event, idx) => (
<div key={event.id} className="relative flex gap-4 pb-6">
{/* Icon */}
<div className="relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-background border">
{eventIcon(event)}
</div>
<div className="flex-1 pt-1">
<p className="text-sm">{event.description}</p>
<p className="text-xs text-muted-foreground mt-0.5">
{format(new Date(event.createdAt), 'MMM d, yyyy HH:mm')}
{event.userId && ` · by ${event.userId}`}
</p>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { useParams } from 'next/navigation';
import { useMemo } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core';
import { PipelineColumn } from '@/components/interests/pipeline-column';
import { apiFetch } from '@/lib/api/client';
import { usePipelineStore } from '@/stores/pipeline-store';
import { PIPELINE_STAGES } from '@/lib/constants';
const STAGE_LABELS: Record<string, string> = {
open: 'Open',
details_sent: 'Details Sent',
in_communication: 'In Communication',
visited: 'Visited',
signed_eoi_nda: 'Signed EOI/NDA',
deposit_10pct: 'Deposit 10%',
contract: 'Contract',
completed: 'Completed',
};
interface InterestRow {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
leadCategory: string | null;
pipelineStage: string;
updatedAt: string;
}
export function PipelineBoard() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const queryClient = useQueryClient();
const { boardFilters } = usePipelineStore();
const { data: allData, isLoading } = useQuery<{ data: InterestRow[] }>({
queryKey: ['interests-board', portSlug],
queryFn: () => apiFetch('/api/v1/interests?limit=500'),
});
const interests = useMemo(() => {
if (!allData?.data) return [];
return allData.data.filter((i) => {
if (boardFilters.leadCategory && i.leadCategory !== boardFilters.leadCategory) return false;
if (boardFilters.search) {
const q = boardFilters.search.toLowerCase();
if (!i.clientName?.toLowerCase().includes(q)) return false;
}
return true;
});
}, [allData, boardFilters]);
const grouped = useMemo(() => {
const map: Record<string, InterestRow[]> = {};
for (const stage of PIPELINE_STAGES) {
map[stage] = [];
}
for (const interest of interests) {
if (map[interest.pipelineStage]) {
map[interest.pipelineStage]!.push(interest);
}
}
return map;
}, [interests]);
async function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
// over.id is a stage when dropped on a column, or an item id when dropped on a card
let newStage = over.id as string;
// If dropped on a card (not a stage), find which stage that card belongs to
if (!PIPELINE_STAGES.includes(newStage as typeof PIPELINE_STAGES[number])) {
const targetInterest = interests.find((i) => i.id === newStage);
if (!targetInterest) return;
newStage = targetInterest.pipelineStage;
}
const interestId = active.id as string;
const currentInterest = interests.find((i) => i.id === interestId);
if (!currentInterest || currentInterest.pipelineStage === newStage) return;
// Optimistic update
queryClient.setQueryData<{ data: InterestRow[] }>(
['interests-board', portSlug],
(old) => {
if (!old) return old;
return {
...old,
data: old.data.map((i) =>
i.id === interestId ? { ...i, pipelineStage: newStage } : i,
),
};
},
);
try {
await apiFetch(`/api/v1/interests/${interestId}/stage`, {
method: 'PATCH',
body: JSON.stringify({ pipelineStage: newStage }),
});
queryClient.invalidateQueries({ queryKey: ['interests'] });
} catch {
// Revert optimistic update
queryClient.invalidateQueries({ queryKey: ['interests-board', portSlug] });
}
}
if (isLoading) {
return <div className="flex gap-3 overflow-x-auto pb-4 animate-pulse h-64" />;
}
return (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div className="flex gap-3 overflow-x-auto pb-4">
{PIPELINE_STAGES.map((stage) => (
<PipelineColumn
key={stage}
stage={stage}
label={STAGE_LABELS[stage] ?? stage}
items={grouped[stage] ?? []}
/>
))}
</div>
</DndContext>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { differenceInDays } from 'date-fns';
import { Badge } from '@/components/ui/badge';
interface PipelineCardProps {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
leadCategory: string | null;
updatedAt: string | Date;
}
const LEAD_CATEGORY_COLORS: Record<string, string> = {
general_interest: 'secondary',
specific_qualified: 'default',
hot_lead: 'destructive',
};
export function PipelineCard({
id,
clientName,
berthMooringNumber,
leadCategory,
updatedAt,
}: PipelineCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const daysInStage = differenceInDays(new Date(), new Date(updatedAt));
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className="bg-card border rounded-md p-3 shadow-sm cursor-grab active:cursor-grabbing space-y-2"
>
<p className="text-sm font-medium truncate">{clientName ?? 'Unknown client'}</p>
{berthMooringNumber && (
<p className="text-xs text-muted-foreground">Berth: {berthMooringNumber}</p>
)}
<div className="flex items-center justify-between gap-2">
{leadCategory && (
<Badge variant={(LEAD_CATEGORY_COLORS[leadCategory] as any) ?? 'secondary'}>
{leadCategory.replace(/_/g, ' ')}
</Badge>
)}
<span className="text-xs text-muted-foreground ml-auto">
{daysInStage}d
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { PipelineCard } from '@/components/interests/pipeline-card';
import { Badge } from '@/components/ui/badge';
interface ColumnItem {
id: string;
clientName: string | null;
berthMooringNumber: string | null;
leadCategory: string | null;
updatedAt: string | Date;
}
interface PipelineColumnProps {
stage: string;
label: string;
items: ColumnItem[];
}
export function PipelineColumn({ stage, label, items }: PipelineColumnProps) {
const { setNodeRef, isOver } = useDroppable({ id: stage });
return (
<div
ref={setNodeRef}
className={`flex flex-col gap-2 min-w-[220px] w-[220px] flex-shrink-0 bg-muted/40 rounded-lg p-3 transition-colors ${
isOver ? 'bg-muted/70 ring-2 ring-primary/30' : ''
}`}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold capitalize">{label}</span>
<Badge variant="outline" className="text-xs">
{items.length}
</Badge>
</div>
<div className="flex flex-col gap-2 overflow-y-auto max-h-[calc(100vh-220px)]">
<SortableContext
items={items.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
{items.map((item) => (
<PipelineCard
key={item.id}
id={item.id}
clientName={item.clientName}
berthMooringNumber={item.berthMooringNumber}
leadCategory={item.leadCategory}
updatedAt={item.updatedAt}
/>
))}
</SortableContext>
{items.length === 0 && (
<div className="text-center py-6 text-xs text-muted-foreground">Empty</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Sparkles, Link, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { apiFetch } from '@/lib/api/client';
interface Recommendation {
id: string;
berthId: string;
matchScore: string | null;
matchReasons: Record<string, number> | null;
source: string;
mooringNumber: string;
area: string | null;
status: string;
lengthFt: string | null;
widthFt: string | null;
draftFt: string | null;
}
interface RecommendationListProps {
interestId: string;
}
export function RecommendationList({ interestId }: RecommendationListProps) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<{ data: Recommendation[] }>({
queryKey: ['interest-recommendations', interestId],
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/recommendations`),
});
const generateMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/interests/${interestId}/recommendations/generate`, {
method: 'POST',
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interest-recommendations', interestId] });
},
});
const recommendations = data?.data ?? [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
AI-scored berth recommendations based on yacht dimensions.
</p>
<Button
size="sm"
variant="outline"
onClick={() => generateMutation.mutate()}
disabled={generateMutation.isPending}
>
{generateMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-1.5 h-4 w-4" />
)}
Generate Recommendations
</Button>
</div>
{isLoading ? (
<div className="space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-16 bg-muted rounded animate-pulse" />
))}
</div>
) : recommendations.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>No recommendations yet. Click "Generate Recommendations" to get started.</p>
</div>
) : (
<div className="space-y-2">
{recommendations.map((rec) => {
const score = rec.matchScore ? Math.round(parseFloat(rec.matchScore)) : 0;
return (
<div
key={rec.id}
className="border rounded-lg p-3 flex items-center gap-4"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">{rec.mooringNumber}</span>
{rec.area && (
<span className="text-xs text-muted-foreground">{rec.area}</span>
)}
<Badge
variant={rec.source === 'ai' ? 'secondary' : 'outline'}
className="text-xs"
>
{rec.source === 'ai' ? 'AI' : 'Manual'}
</Badge>
</div>
{rec.matchScore && (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: `${score}%` }}
/>
</div>
<span className="text-xs text-muted-foreground w-8">{score}%</span>
</div>
)}
{(rec.lengthFt || rec.widthFt) && (
<p className="text-xs text-muted-foreground mt-1">
{rec.lengthFt && `${rec.lengthFt}ft length`}
{rec.lengthFt && rec.widthFt && ' · '}
{rec.widthFt && `${rec.widthFt}ft beam`}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,187 @@
'use client';
import Link from 'next/link';
import { format } from 'date-fns';
import { MoreHorizontal, Eye, Send, CreditCard, Trash2, FileText } from 'lucide-react';
import type { ColumnDef } from '@tanstack/react-table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export interface InvoiceRow {
id: string;
invoiceNumber: string;
clientName: string;
total: string;
currency: string;
status: string;
paymentStatus: string | null;
dueDate: string;
pdfFileId: string | null;
archivedAt: string | null;
createdAt: string;
}
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700 border-gray-200',
sent: 'bg-blue-100 text-blue-700 border-blue-200',
paid: 'bg-green-100 text-green-700 border-green-200',
overdue: 'bg-red-100 text-red-700 border-red-200',
cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
};
interface GetColumnsOptions {
portSlug: string;
onSend?: (invoice: InvoiceRow) => void;
onRecordPayment?: (invoice: InvoiceRow) => void;
onDelete?: (invoice: InvoiceRow) => void;
}
export function getInvoiceColumns({
portSlug,
onSend,
onRecordPayment,
onDelete,
}: GetColumnsOptions): ColumnDef<InvoiceRow, unknown>[] {
const today = new Date().toISOString().split('T')[0]!;
return [
{
id: 'invoiceNumber',
accessorKey: 'invoiceNumber',
header: 'Invoice #',
cell: ({ row }) => (
<Link
href={`/${portSlug}/invoices/${row.original.id}`}
className="font-medium text-primary hover:underline font-mono text-sm"
onClick={(e) => e.stopPropagation()}
>
{row.original.invoiceNumber}
</Link>
),
},
{
id: 'clientName',
accessorKey: 'clientName',
header: 'Client',
cell: ({ getValue }) => (
<span className="font-medium">{getValue() as string}</span>
),
},
{
id: 'total',
header: 'Total',
enableSorting: false,
cell: ({ row }) => (
<span className="font-medium tabular-nums">
{Number(row.original.total).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{row.original.currency}
</span>
),
},
{
id: 'status',
accessorKey: 'status',
header: 'Status',
cell: ({ getValue }) => {
const status = (getValue() as string) ?? 'draft';
const colorClass = STATUS_COLORS[status] ?? STATUS_COLORS.draft;
return (
<Badge
variant="outline"
className={`capitalize text-xs border ${colorClass}`}
>
{status}
</Badge>
);
},
},
{
id: 'dueDate',
accessorKey: 'dueDate',
header: 'Due Date',
cell: ({ row }) => {
const due = row.original.dueDate;
const isOverdue =
row.original.status === 'sent' && due < today;
return (
<span
className={`text-sm ${isOverdue ? 'text-red-600 font-medium' : 'text-muted-foreground'}`}
>
{format(new Date(due), 'MMM d, yyyy')}
</span>
);
},
},
{
id: 'actions',
header: '',
enableSorting: false,
size: 48,
cell: ({ row }) => {
const invoice = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/${portSlug}/invoices/${invoice.id}`}>
<Eye className="mr-2 h-3.5 w-3.5" />
View
</Link>
</DropdownMenuItem>
{invoice.pdfFileId && (
<DropdownMenuItem asChild>
<Link href={`/api/v1/files/${invoice.pdfFileId}/preview`} target="_blank">
<FileText className="mr-2 h-3.5 w-3.5" />
View PDF
</Link>
</DropdownMenuItem>
)}
{invoice.status === 'draft' && onSend && (
<DropdownMenuItem onClick={() => onSend(invoice)}>
<Send className="mr-2 h-3.5 w-3.5" />
Send
</DropdownMenuItem>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') &&
onRecordPayment && (
<DropdownMenuItem onClick={() => onRecordPayment(invoice)}>
<CreditCard className="mr-2 h-3.5 w-3.5" />
Record Payment
</DropdownMenuItem>
)}
{invoice.status === 'draft' && onDelete && (
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(invoice)}
>
<Trash2 className="mr-2 h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
}

View File

@@ -0,0 +1,367 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { format } from 'date-fns';
import { Loader2, Send, CreditCard } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { InvoicePdfPreview } from './invoice-pdf-preview';
import { apiFetch } from '@/lib/api/client';
import { recordPaymentSchema, type RecordPaymentInput } from '@/lib/validators/invoices';
const STATUS_COLORS: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700 border-gray-200',
sent: 'bg-blue-100 text-blue-700 border-blue-200',
paid: 'bg-green-100 text-green-700 border-green-200',
overdue: 'bg-red-100 text-red-700 border-red-200',
cancelled: 'bg-gray-100 text-gray-500 border-gray-200',
};
interface InvoiceDetailProps {
invoiceId: string;
}
export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
const queryClient = useQueryClient();
const [tab, setTab] = useState('overview');
const { data, isLoading, error } = useQuery<{ data: any }>({
queryKey: ['invoices', invoiceId],
queryFn: () => apiFetch(`/api/v1/invoices/${invoiceId}`),
});
const sendMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/invoices/${invoiceId}/send`, { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
});
const paymentForm = useForm<RecordPaymentInput>({
resolver: zodResolver(recordPaymentSchema),
defaultValues: { paymentDate: new Date().toISOString().split('T')[0] },
});
const paymentMutation = useMutation({
mutationFn: (values: RecordPaymentInput) =>
apiFetch(`/api/v1/invoices/${invoiceId}/payment`, {
method: 'PATCH',
body: JSON.stringify(values),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
});
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error || !data?.data) {
return (
<div className="p-6 text-center text-muted-foreground">
Failed to load invoice details.
</div>
);
}
const invoice = data.data;
const statusColor = STATUS_COLORS[invoice.status] ?? STATUS_COLORS.draft;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold font-mono">{invoice.invoiceNumber}</h2>
<Badge variant="outline" className={`capitalize text-sm border ${statusColor}`}>
{invoice.status}
</Badge>
</div>
<p className="text-sm text-muted-foreground mt-0.5">{invoice.clientName}</p>
</div>
<div className="flex items-center gap-2">
{invoice.status === 'draft' && (
<Button
variant="outline"
size="sm"
onClick={() => sendMutation.mutate()}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
Send Invoice
</Button>
)}
</div>
</div>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="expenses">Linked Expenses</TabsTrigger>
<TabsTrigger value="pdf">PDF Preview</TabsTrigger>
<TabsTrigger value="payment">Payment</TabsTrigger>
</TabsList>
{/* Overview */}
<TabsContent value="overview" className="space-y-4 pt-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Total</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold tabular-nums">
{Number(invoice.total).toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{' '}
{invoice.currency}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Due Date</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm">{invoice.dueDate}</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Payment Terms</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm capitalize">{invoice.paymentTerms}</p>
</CardContent>
</Card>
</div>
{/* Line items */}
{invoice.lineItems && invoice.lineItems.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Line Items</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground border-b pb-2">
<span className="col-span-6">Description</span>
<span className="col-span-2 text-right">Qty</span>
<span className="col-span-2 text-right">Unit Price</span>
<span className="col-span-2 text-right">Total</span>
</div>
{invoice.lineItems.map((li: any) => (
<div key={li.id} className="grid grid-cols-12 gap-2 text-sm">
<span className="col-span-6">{li.description}</span>
<span className="col-span-2 text-right tabular-nums">{li.quantity}</span>
<span className="col-span-2 text-right tabular-nums">
{Number(li.unitPrice).toFixed(2)}
</span>
<span className="col-span-2 text-right tabular-nums font-medium">
{Number(li.total).toFixed(2)}
</span>
</div>
))}
</div>
{/* Totals */}
<div className="mt-4 border-t pt-4 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">
{Number(invoice.subtotal).toFixed(2)} {invoice.currency}
</span>
</div>
{Number(invoice.discountAmount) > 0 && (
<div className="flex justify-between text-green-600">
<span>Discount ({invoice.discountPct}%)</span>
<span className="tabular-nums">
-{Number(invoice.discountAmount).toFixed(2)} {invoice.currency}
</span>
</div>
)}
{Number(invoice.feeAmount) > 0 && (
<div className="flex justify-between">
<span className="text-muted-foreground">Fee ({invoice.feePct}%)</span>
<span className="tabular-nums">
+{Number(invoice.feeAmount).toFixed(2)} {invoice.currency}
</span>
</div>
)}
<div className="flex justify-between font-semibold border-t pt-2">
<span>Total</span>
<span className="tabular-nums">
{Number(invoice.total).toFixed(2)} {invoice.currency}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{invoice.notes && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Notes</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{invoice.notes}
</p>
</CardContent>
</Card>
)}
</TabsContent>
{/* Linked Expenses */}
<TabsContent value="expenses" className="pt-4">
{invoice.linkedExpenses && invoice.linkedExpenses.length > 0 ? (
<div className="space-y-2">
{invoice.linkedExpenses.map((exp: any) => (
<div
key={exp.id}
className="flex items-center justify-between p-3 border rounded-md text-sm"
>
<div>
<p className="font-medium">
{exp.establishmentName ?? 'Unnamed Expense'}
</p>
<p className="text-muted-foreground text-xs">
{exp.category ?? '—'} &middot; {exp.expenseDate}
</p>
</div>
<span className="font-medium tabular-nums">
{Number(exp.amount).toFixed(2)} {exp.currency}
</span>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground py-6 text-center">
No expenses linked to this invoice.
</p>
)}
</TabsContent>
{/* PDF Preview */}
<TabsContent value="pdf" className="pt-4">
<InvoicePdfPreview
invoiceId={invoiceId}
pdfFileId={invoice.pdfFileId}
/>
</TabsContent>
{/* Payment */}
<TabsContent value="payment" className="pt-4">
{invoice.status === 'paid' ? (
<Card>
<CardContent className="pt-6 space-y-3 text-sm">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="bg-green-100 text-green-700 border-green-200"
>
Paid
</Badge>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-muted-foreground">Payment Date</span>
<p className="mt-0.5">{invoice.paymentDate ?? '—'}</p>
</div>
<div>
<span className="text-muted-foreground">Method</span>
<p className="mt-0.5 capitalize">
{invoice.paymentMethod ?? '—'}
</p>
</div>
<div>
<span className="text-muted-foreground">Reference</span>
<p className="mt-0.5">{invoice.paymentReference ?? '—'}</p>
</div>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Record Payment</CardTitle>
</CardHeader>
<CardContent>
<form
onSubmit={paymentForm.handleSubmit((values) =>
paymentMutation.mutate(values),
)}
className="space-y-4"
>
<div className="space-y-1">
<Label htmlFor="paymentDate">Payment Date</Label>
<Input
id="paymentDate"
type="date"
{...paymentForm.register('paymentDate')}
/>
{paymentForm.formState.errors.paymentDate && (
<p className="text-xs text-destructive">
{paymentForm.formState.errors.paymentDate.message}
</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="paymentMethod">Payment Method</Label>
<Input
id="paymentMethod"
placeholder="e.g. bank_transfer, credit_card"
{...paymentForm.register('paymentMethod')}
/>
</div>
<div className="space-y-1">
<Label htmlFor="paymentReference">Reference / Transaction ID</Label>
<Input
id="paymentReference"
placeholder="Optional reference"
{...paymentForm.register('paymentReference')}
/>
</div>
<Button
type="submit"
disabled={paymentMutation.isPending}
>
{paymentMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<CreditCard className="mr-1.5 h-4 w-4" />
)}
Mark as Paid
</Button>
</form>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import type { FilterDefinition } from '@/components/shared/filter-bar';
export const invoiceFilterDefinitions: FilterDefinition[] = [
{
key: 'search',
label: 'Search',
type: 'text',
placeholder: 'Search by invoice # or client...',
},
{
key: 'status',
label: 'Status',
type: 'multi-select',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Paid', value: 'paid' },
{ label: 'Overdue', value: 'overdue' },
{ label: 'Cancelled', value: 'cancelled' },
],
},
{
key: 'clientName',
label: 'Client Name',
type: 'text',
placeholder: 'Filter by client name...',
},
{
key: 'dateFrom',
label: 'Due From',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'dateTo',
label: 'Due To',
type: 'text',
placeholder: 'YYYY-MM-DD',
},
{
key: 'includeArchived',
label: 'Include Archived',
type: 'boolean',
},
];

View File

@@ -0,0 +1,136 @@
'use client';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface LineItem {
description: string;
quantity: number;
unitPrice: number;
}
interface InvoiceLineItemsProps {
name?: string;
}
export function InvoiceLineItems({ name = 'lineItems' }: InvoiceLineItemsProps) {
const { register, watch, formState: { errors } } = useFormContext();
const { fields, append, remove } = useFieldArray({ name });
const lineItems: LineItem[] = watch(name) ?? [];
const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
0,
);
return (
<div className="space-y-3">
{/* Header */}
{fields.length > 0 && (
<div className="grid grid-cols-12 gap-2 text-xs font-medium text-muted-foreground px-1">
<span className="col-span-6">Description</span>
<span className="col-span-2 text-right">Qty</span>
<span className="col-span-2 text-right">Unit Price</span>
<span className="col-span-1 text-right">Total</span>
<span className="col-span-1" />
</div>
)}
{/* Line items */}
{fields.map((field, index) => {
const qty = Number(lineItems[index]?.quantity) || 0;
const price = Number(lineItems[index]?.unitPrice) || 0;
const lineTotal = qty * price;
return (
<div key={field.id} className="grid grid-cols-12 gap-2 items-start">
<div className="col-span-6">
<Input
{...register(`${name}.${index}.description`)}
placeholder="Description"
className="h-8 text-sm"
/>
</div>
<div className="col-span-2">
<Input
{...register(`${name}.${index}.quantity`, { valueAsNumber: true })}
type="number"
min="0.001"
step="any"
placeholder="1"
className="h-8 text-sm text-right"
/>
</div>
<div className="col-span-2">
<Input
{...register(`${name}.${index}.unitPrice`, { valueAsNumber: true })}
type="number"
min="0"
step="any"
placeholder="0.00"
className="h-8 text-sm text-right"
/>
</div>
<div className="col-span-1 flex items-center justify-end h-8">
<span className="text-sm tabular-nums">
{lineTotal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
<div className="col-span-1 flex items-center justify-end h-8">
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => remove(index)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
})}
{/* Empty state */}
{fields.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center border border-dashed rounded-md">
No line items yet. Add your first item below.
</p>
)}
{/* Add button + subtotal */}
<div className="flex items-center justify-between pt-1">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
append({ description: '', quantity: 1, unitPrice: 0 })
}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Add Line Item
</Button>
{fields.length > 0 && (
<div className="text-sm font-medium">
Subtotal:{' '}
<span className="tabular-nums">
{subtotal.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, RefreshCw, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
interface InvoicePdfPreviewProps {
invoiceId: string;
pdfFileId?: string | null;
}
export function InvoicePdfPreview({ invoiceId, pdfFileId: initialPdfFileId }: InvoicePdfPreviewProps) {
const queryClient = useQueryClient();
const [pdfFileId, setPdfFileId] = useState(initialPdfFileId);
const { data: previewData, isLoading: previewLoading } = useQuery<{ url: string; mimeType: string }>({
queryKey: ['file-preview', pdfFileId],
queryFn: () => apiFetch(`/api/v1/files/${pdfFileId}/preview`),
enabled: !!pdfFileId,
});
const regenerateMutation = useMutation({
mutationFn: () =>
apiFetch(`/api/v1/invoices/${invoiceId}/generate-pdf`, { method: 'POST' }),
onSuccess: (data: any) => {
const fileId = data?.data?.id;
if (fileId) {
setPdfFileId(fileId);
queryClient.invalidateQueries({ queryKey: ['invoices', invoiceId] });
queryClient.invalidateQueries({ queryKey: ['file-preview', fileId] });
}
},
});
if (!pdfFileId) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12 border border-dashed rounded-md">
<FileText className="h-10 w-10 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No PDF generated yet</p>
<Button
variant="outline"
size="sm"
onClick={() => regenerateMutation.mutate()}
disabled={regenerateMutation.isPending}
>
{regenerateMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-4 w-4" />
)}
Generate PDF
</Button>
</div>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={() => regenerateMutation.mutate()}
disabled={regenerateMutation.isPending}
>
{regenerateMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-1.5 h-4 w-4" />
)}
Regenerate PDF
</Button>
</div>
{previewLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : previewData?.url ? (
<iframe
src={previewData.url}
className="w-full rounded border"
style={{ height: '600px' }}
title="Invoice PDF"
/>
) : (
<div className="flex items-center justify-center py-12 border rounded">
<p className="text-sm text-muted-foreground">Unable to load PDF preview</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,129 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Fragment } from 'react';
import { ChevronRight } from 'lucide-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { usePortContext } from '@/providers/port-provider';
// Human-readable labels for route segments
const SEGMENT_LABELS: Record<string, string> = {
dashboard: 'Dashboard',
clients: 'Clients',
interests: 'Interests',
berths: 'Berths',
documents: 'Documents',
files: 'Files',
expenses: 'Expenses',
invoices: 'Invoices',
email: 'Email',
reminders: 'Reminders',
settings: 'Settings',
admin: 'Administration',
reports: 'Reports',
new: 'New',
edit: 'Edit',
profile: 'Profile',
};
function formatSegment(segment: string): string {
return SEGMENT_LABELS[segment] ?? segment.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
export function Breadcrumbs() {
const pathname = usePathname();
const { currentPort, currentPortSlug } = usePortContext();
// Split pathname and filter empty segments
const rawSegments = pathname.split('/').filter(Boolean);
// Remove the portSlug segment from display
const segments = currentPortSlug
? rawSegments.filter((seg) => seg !== currentPortSlug)
: rawSegments;
if (segments.length === 0) {
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage className="text-foreground font-medium">
{currentPort?.name ?? 'Port Nimara CRM'}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
);
}
// Build href for each segment
const crumbs = segments.map((segment, index) => {
const segmentsUpToHere = rawSegments.slice(0, rawSegments.indexOf(segment, index) + 1);
const href = '/' + segmentsUpToHere.join('/');
const label = formatSegment(segment);
const isLast = index === segments.length - 1;
return { label, href, isLast };
});
return (
<Breadcrumb>
<BreadcrumbList>
{currentPort && (
<>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link
href={`/${currentPortSlug}/dashboard` as any}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{currentPort.name}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{crumbs.length > 0 && (
<BreadcrumbSeparator>
<ChevronRight className="w-3 h-3" />
</BreadcrumbSeparator>
)}
</>
)}
{crumbs.map((crumb, index) => (
<Fragment key={crumb.href}>
<BreadcrumbItem>
{crumb.isLast ? (
<BreadcrumbPage className="font-medium text-foreground">
{crumb.label}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link
href={crumb.href as any}
className="text-muted-foreground hover:text-foreground transition-colors"
>
{crumb.label}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{!crumb.isLast && (
<BreadcrumbSeparator>
<ChevronRight className="w-3 h-3" />
</BreadcrumbSeparator>
)}
</Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Port } from '@/lib/db/schema/ports';
interface PortSwitcherProps {
ports: Port[];
}
export function PortSwitcher({ ports }: PortSwitcherProps) {
const router = useRouter();
const queryClient = useQueryClient();
const currentPortId = useUIStore((s) => s.currentPortId);
const setPort = useUIStore((s) => s.setPort);
// Hidden when user has access to only one port
if (ports.length <= 1) return null;
function handlePortChange(portId: string) {
const port = ports.find((p) => p.id === portId);
if (!port) return;
setPort(port.id, port.slug);
// Invalidate all cached queries — they are port-scoped
queryClient.invalidateQueries();
// Navigate to the selected port's dashboard
router.push(`/${port.slug}/dashboard` as any);
}
return (
<Select value={currentPortId ?? undefined} onValueChange={handlePortChange}>
<SelectTrigger className="w-40 h-8 text-sm">
<SelectValue placeholder="Select port..." />
</SelectTrigger>
<SelectContent>
{ports.map((port) => (
<SelectItem key={port.id} value={port.id}>
{port.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View File

@@ -0,0 +1,345 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
LayoutDashboard,
Users,
Bookmark,
Anchor,
Receipt,
FileText,
FolderOpen,
Mail,
Bell,
Settings,
Shield,
ChevronLeft,
ChevronRight,
Menu,
ChevronDown,
ChevronUp,
LogOut,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/ui-store';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { UserPortRole } from '@/lib/db/schema/users';
import type { Role } from '@/lib/db/schema/users';
interface SidebarProps {
portRoles: (UserPortRole & { port: { id: string; slug: string; name: string }; role: Role })[];
}
interface NavItem {
href: string;
label: string;
icon: React.ElementType;
exact?: boolean;
}
interface NavSection {
title: string;
items: NavItem[];
adminRequired?: boolean;
}
function buildNavSections(portSlug: string | undefined): NavSection[] {
const base = portSlug ? `/${portSlug}` : '';
return [
{
title: 'Main',
items: [
{ href: `${base}/dashboard`, label: 'Dashboard', icon: LayoutDashboard },
{ href: `${base}/clients`, label: 'Clients', icon: Users },
{ href: `${base}/interests`, label: 'Interests', icon: Bookmark },
{ href: `${base}/berths`, label: 'Berths', icon: Anchor },
],
},
{
title: 'Documents',
items: [
{ href: `${base}/documents`, label: 'Documents', icon: FileText },
{ href: `${base}/documents/files`, label: 'Files', icon: FolderOpen },
],
},
{
title: 'Financial',
items: [
{ href: `${base}/expenses`, label: 'Expenses', icon: Receipt },
{ href: `${base}/invoices`, label: 'Invoices', icon: FileText },
],
},
{
title: 'Communication',
items: [
{ href: `${base}/email`, label: 'Email', icon: Mail },
{ href: `${base}/reminders`, label: 'Reminders', icon: Bell },
],
},
{
title: 'Admin',
adminRequired: true,
items: [
{ href: `${base}/settings`, label: 'Settings', icon: Settings },
{ href: `${base}/admin`, label: 'Administration', icon: Shield },
],
},
];
}
function NavItemLink({
item,
collapsed,
active,
}: {
item: NavItem;
collapsed: boolean;
active: boolean;
}) {
const content = (
<Link
href={item.href as any}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-150',
'text-[#cdcfd6] hover:bg-[#171f35] hover:text-white',
active && 'border-l-2 border-[#3a7bc8] bg-[#3a7bc810] text-white pl-[10px]',
collapsed && 'justify-center px-2',
)}
>
<item.icon
className={cn(
'shrink-0',
active ? 'text-[#3a7bc8]' : 'text-[#83aab1]',
collapsed ? 'w-5 h-5' : 'w-4 h-4',
)}
/>
{!collapsed && <span>{item.label}</span>}
</Link>
);
if (collapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" className="font-medium">
{item.label}
</TooltipContent>
</Tooltip>
);
}
return content;
}
function SidebarContent({
collapsed,
portSlug,
portRoles,
hasAdminAccess,
}: {
collapsed: boolean;
portSlug: string | undefined;
portRoles: SidebarProps['portRoles'];
hasAdminAccess: boolean;
}) {
const pathname = usePathname();
const [adminExpanded, setAdminExpanded] = useState(false);
const sections = buildNavSections(portSlug);
function isActive(href: string, exact?: boolean): boolean {
if (exact) return pathname === href;
return pathname.startsWith(href);
}
return (
<div className="flex flex-col h-full bg-[#1e2844]">
{/* Logo area */}
<div
className={cn(
'flex items-center gap-3 px-4 py-5 border-b border-[#474e66]',
collapsed && 'justify-center px-2',
)}
>
<div className="shrink-0 w-8 h-8 rounded-md bg-[#3a7bc8] flex items-center justify-center">
<span className="text-white font-bold text-sm">PN</span>
</div>
{!collapsed && (
<div className="min-w-0">
<p className="text-white font-semibold text-sm leading-tight truncate">Port Nimara</p>
<p className="text-[#83aab1] text-xs truncate">Marina CRM</p>
</div>
)}
</div>
{/* Nav */}
<ScrollArea className="flex-1 py-2">
<TooltipProvider delayDuration={0}>
<nav className="px-2 space-y-4">
{sections.map((section) => {
if (section.adminRequired && !hasAdminAccess) return null;
return (
<div key={section.title}>
{!collapsed && (
<div className="flex items-center justify-between px-1 mb-1">
<span className="text-[#71768a] text-xs font-medium uppercase tracking-wider">
{section.title}
</span>
{section.adminRequired && (
<button
onClick={() => setAdminExpanded((v) => !v)}
className="text-[#71768a] hover:text-[#cdcfd6] transition-colors"
>
{adminExpanded ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
)}
</div>
)}
{(!section.adminRequired || adminExpanded || collapsed) && (
<ul className="space-y-0.5">
{section.items.map((item) => (
<li key={item.href}>
<NavItemLink
item={item}
collapsed={collapsed}
active={isActive(item.href, item.exact)}
/>
</li>
))}
</ul>
)}
<Separator className="mt-3 bg-[#474e66]/50" />
</div>
);
})}
</nav>
</TooltipProvider>
</ScrollArea>
{/* User footer */}
<div className={cn('border-t border-[#474e66] p-3', collapsed && 'flex justify-center')}>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Avatar className="w-8 h-8 cursor-pointer">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
U
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent side="right">User Profile</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center gap-3">
<Avatar className="w-8 h-8 shrink-0">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-[#3a7bc8] text-white text-xs font-semibold">
U
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">User Name</p>
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 text-[#83aab1] border-[#474e66] mt-0.5"
>
{portRoles[0]?.role?.name ?? 'Staff'}
</Badge>
</div>
</div>
)}
</div>
</div>
);
}
export function Sidebar({ portRoles }: SidebarProps) {
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
// Check for admin access based on role permissions
const hasAdminAccess = portRoles.some(
(pr) => pr.role?.permissions?.admin?.manage_users || pr.role?.permissions?.admin?.manage_settings,
);
return (
<>
{/* Desktop sidebar */}
<aside
className={cn(
'hidden md:flex flex-col h-screen border-r border-[#474e66] transition-all duration-200 ease-in-out shrink-0',
sidebarCollapsed ? 'w-sidebar-collapsed' : 'w-sidebar',
)}
style={{ backgroundColor: '#1e2844' }}
>
<SidebarContent
collapsed={sidebarCollapsed}
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
/>
{/* Collapse toggle */}
<button
onClick={toggleSidebar}
className={cn(
'absolute top-1/2 -translate-y-1/2 -right-3 z-10',
'w-6 h-6 rounded-full bg-[#1e2844] border border-[#474e66]',
'flex items-center justify-center text-[#cdcfd6]',
'hover:bg-[#3a7bc8] hover:border-[#3a7bc8] hover:text-white transition-colors',
)}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? (
<ChevronRight className="w-3 h-3" />
) : (
<ChevronLeft className="w-3 h-3" />
)}
</button>
</aside>
{/* Mobile drawer */}
<Sheet>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="md:hidden fixed top-3 left-3 z-50 text-white bg-[#1e2844] hover:bg-[#171f35]"
>
<Menu className="w-5 h-5" />
<span className="sr-only">Open navigation</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-sidebar border-r-0">
<SidebarContent
collapsed={false}
portSlug={currentPortSlug ?? undefined}
portRoles={portRoles}
hasAdminAccess={hasAdminAccess}
/>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { Plus, Moon, Sun, LogOut, User, Settings } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useUIStore } from '@/stores/ui-store';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { PortSwitcher } from '@/components/layout/port-switcher';
import { Breadcrumbs } from '@/components/layout/breadcrumbs';
import { CommandSearch, SearchTrigger } from '@/components/search/command-search';
import { NotificationBell } from '@/components/notifications/notification-bell';
import type { Port } from '@/lib/db/schema/ports';
interface TopbarProps {
ports: Port[];
}
export function Topbar({ ports }: TopbarProps) {
const router = useRouter();
const currentPortSlug = useUIStore((s) => s.currentPortSlug);
const darkMode = useUIStore((s) => s.darkMode);
const toggleDarkMode = useUIStore((s) => s.toggleDarkMode);
const base = currentPortSlug ? `/${currentPortSlug}` : '';
function handleToggleDarkMode() {
toggleDarkMode();
document.documentElement.classList.toggle('dark');
}
return (
<header className="h-14 border-b border-border bg-background flex items-center gap-3 px-4 shrink-0">
{/* Breadcrumbs / page title */}
<div className="flex-1 min-w-0">
<Breadcrumbs />
</div>
{/* Actions row */}
<div className="flex items-center gap-2 shrink-0">
{/* Global search — inline with dropdown results */}
<CommandSearch />
{/* Port switcher — hidden for single port */}
<PortSwitcher ports={ports} />
{/* + New dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" className="bg-brand hover:bg-brand-500 text-white gap-1.5">
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">New</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuLabel className="text-xs text-muted-foreground">Create</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`${base}/clients/new` as any)}>
New Client
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`${base}/interests/new` as any)}>
New Interest
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`${base}/expenses/new` as any)}>
New Expense
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`${base}/reminders/new` as any)}>
New Reminder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Notification bell — real-time via socket */}
<NotificationBell />
<Separator orientation="vertical" className="h-6" />
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Avatar className="w-7 h-7">
<AvatarImage src={undefined} />
<AvatarFallback className="bg-brand text-white text-xs font-semibold">
U
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push(`${base}/settings/profile` as any)}>
<User className="w-4 h-4 mr-2" />
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push(`${base}/settings` as any)}>
<Settings className="w-4 h-4 mr-2" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleToggleDarkMode}>
{darkMode ? (
<>
<Sun className="w-4 h-4 mr-2" />
Light Mode
</>
) : (
<>
<Moon className="w-4 h-4 mr-2" />
Dark Mode
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => router.push('/api/auth/sign-out')}
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import { Bell } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { apiFetch } from '@/lib/api/client';
import { useNotifications } from '@/hooks/use-notifications';
import { NotificationItem } from './notification-item';
interface NotificationListResponse {
data: Array<{
id: string;
type: string;
title: string;
description: string | null;
link: string | null;
isRead: boolean;
createdAt: Date;
}>;
total: number;
}
export function NotificationBell() {
const { unreadCount } = useNotifications();
const queryClient = useQueryClient();
const { data, isLoading } = useQuery<NotificationListResponse>({
queryKey: ['notifications', 'list'],
queryFn: () => apiFetch('/api/v1/notifications?limit=20'),
staleTime: 30_000,
});
const markReadMutation = useMutation({
mutationFn: (notificationId: string) =>
apiFetch(`/api/v1/notifications/${notificationId}`, { method: 'PATCH' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const markAllReadMutation = useMutation({
mutationFn: () => apiFetch('/api/v1/notifications/read-all', { method: 'POST' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
},
});
const notifications = data?.data ?? [];
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-blue-500 text-[10px] font-bold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3">
<h4 className="text-sm font-semibold">Notifications</h4>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-auto p-0 text-xs text-muted-foreground hover:text-foreground"
onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending}
>
Mark all read
</Button>
)}
</div>
<Separator />
{/* Notification list */}
<ScrollArea className="max-h-[400px]">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
Loading...
</div>
) : notifications.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-sm text-muted-foreground">
<Bell className="mb-2 h-8 w-8 opacity-30" />
No notifications
</div>
) : (
<div className="divide-y">
{notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onMarkRead={(id) => markReadMutation.mutate(id)}
/>
))}
</div>
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import { formatDistanceToNow } from 'date-fns';
import { useRouter } from 'next/navigation';
interface NotificationItemProps {
notification: {
id: string;
type: string;
title: string;
description: string | null;
link: string | null;
isRead: boolean;
createdAt: Date;
};
onMarkRead: (id: string) => void;
}
export function NotificationItem({ notification, onMarkRead }: NotificationItemProps) {
const router = useRouter();
const handleClick = () => {
if (!notification.isRead) {
onMarkRead(notification.id);
}
if (notification.link) {
router.push(notification.link as any);
}
};
return (
<button
type="button"
onClick={handleClick}
className="w-full text-left flex items-start gap-3 px-4 py-3 hover:bg-muted/50 transition-colors"
>
{/* Unread indicator */}
<div className="mt-1.5 flex-shrink-0">
{!notification.isRead ? (
<span className="block h-2 w-2 rounded-full bg-blue-500" />
) : (
<span className="block h-2 w-2 rounded-full bg-transparent" />
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<p
className={`text-sm leading-snug truncate ${
notification.isRead ? 'text-muted-foreground' : 'text-foreground font-medium'
}`}
>
{notification.title}
</p>
{notification.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{notification.description}
</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(notification.createdAt), { addSuffix: true })}
</p>
</div>
</button>
);
}

View File

@@ -0,0 +1,48 @@
import { type LucideIcon } from 'lucide-react';
import Link from 'next/link';
import { cn } from '@/lib/utils';
interface PortalCardProps {
title: string;
value: number | string;
description?: string;
icon: LucideIcon;
href?: string;
className?: string;
}
export function PortalCard({
title,
value,
description,
icon: Icon,
href,
className,
}: PortalCardProps) {
const content = (
<div
className={cn(
'bg-white rounded-lg border p-6 flex items-start gap-4',
href && 'hover:border-[#1e2844] hover:shadow-sm transition-all cursor-pointer',
className,
)}
>
<div className="p-2 rounded-lg bg-[#1e2844]/8">
<Icon className="h-5 w-5 text-[#1e2844]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-semibold text-gray-900 mt-0.5">{value}</p>
{description && (
<p className="text-xs text-gray-400 mt-1">{description}</p>
)}
</div>
</div>
);
if (href) {
return <Link href={href as any}>{content}</Link>;
}
return content;
}

View File

@@ -0,0 +1,59 @@
'use client';
import { useRouter } from 'next/navigation';
import { LogOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface PortalHeaderProps {
portName: string;
portLogoUrl?: string | null;
clientName: string;
}
export function PortalHeader({ portName, portLogoUrl, clientName }: PortalHeaderProps) {
const router = useRouter();
async function handleLogout() {
await fetch('/api/portal/auth/logout', { method: 'POST' });
router.push('/portal/login' as any);
}
return (
<header className="border-b bg-white sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
{portLogoUrl ? (
<img
src={portLogoUrl}
alt={portName}
className="h-8 w-auto object-contain"
/>
) : (
<div className="h-8 w-8 rounded bg-[#1e2844] flex items-center justify-center">
<span className="text-white text-xs font-bold">
{portName.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<p className="text-sm font-semibold text-gray-900">{portName}</p>
<p className="text-xs text-gray-500">Client Portal</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600 hidden sm:block">{clientName}</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
className="text-gray-500 hover:text-gray-900"
>
<LogOut className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Sign out</span>
</Button>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LayoutDashboard, Anchor, FileText, Receipt } from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ label: 'Dashboard', href: '/portal/dashboard', icon: LayoutDashboard },
{ label: 'Interests', href: '/portal/interests', icon: Anchor },
{ label: 'Documents', href: '/portal/documents', icon: FileText },
{ label: 'Invoices', href: '/portal/invoices', icon: Receipt },
];
export function PortalNav() {
const pathname = usePathname();
return (
<nav className="border-b bg-white">
<div className="max-w-5xl mx-auto px-4 sm:px-6">
<div className="flex gap-1 -mb-px overflow-x-auto">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<Link
key={item.href}
href={item.href as any}
className={cn(
'flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap',
isActive
? 'border-[#1e2844] text-[#1e2844]'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300',
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { apiFetch } from '@/lib/api/client';
import type { RequestReportInput } from '@/lib/validators/reports';
const REPORT_TYPE_LABELS: Record<string, string> = {
pipeline: 'Pipeline Summary',
revenue: 'Revenue Report',
activity: 'Activity Log',
occupancy: 'Berth Occupancy',
};
export function GenerateReportForm() {
const queryClient = useQueryClient();
const [reportType, setReportType] = useState<string>('');
const [name, setName] = useState<string>('');
const [dateFrom, setDateFrom] = useState<string>('');
const [dateTo, setDateTo] = useState<string>('');
const mutation = useMutation({
mutationFn: (data: RequestReportInput) =>
apiFetch('/api/v1/reports', { method: 'POST', body: data }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reports'] });
setReportType('');
setName('');
setDateFrom('');
setDateTo('');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!reportType || !name.trim()) return;
const payload: RequestReportInput = {
reportType: reportType as RequestReportInput['reportType'],
name: name.trim(),
parameters: {
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
},
};
mutation.mutate(payload);
};
return (
<Card>
<CardHeader>
<CardTitle>Generate Report</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="reportType">Report Type</Label>
<Select value={reportType} onValueChange={setReportType}>
<SelectTrigger id="reportType">
<SelectValue placeholder="Select a report type..." />
</SelectTrigger>
<SelectContent>
{Object.entries(REPORT_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="name">Report Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Pipeline Summary Q1 2025"
maxLength={200}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="dateFrom">Date From (optional)</Label>
<Input
id="dateFrom"
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="dateTo">Date To (optional)</Label>
<Input
id="dateTo"
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
</div>
{mutation.isError && (
<p className="text-sm text-destructive">
{mutation.error instanceof Error
? mutation.error.message
: 'Failed to queue report. Please try again.'}
</p>
)}
{mutation.isSuccess && (
<p className="text-sm text-green-600">
Report queued successfully. You will be notified when it is ready.
</p>
)}
<Button
type="submit"
disabled={!reportType || !name.trim() || mutation.isPending}
>
{mutation.isPending ? 'Queuing...' : 'Generate Report'}
</Button>
</form>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { Loader2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
type ReportStatus = 'queued' | 'processing' | 'ready' | 'failed';
interface ReportStatusBadgeProps {
status: ReportStatus;
}
export function ReportStatusBadge({ status }: ReportStatusBadgeProps) {
switch (status) {
case 'queued':
return <Badge variant="outline">Queued</Badge>;
case 'processing':
return (
<Badge variant="secondary" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Processing
</Badge>
);
case 'ready':
return (
<Badge className="bg-green-600 text-white hover:bg-green-700">
Ready
</Badge>
);
case 'failed':
return <Badge variant="destructive">Failed</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
}

View File

@@ -0,0 +1,164 @@
'use client';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { ReportStatusBadge } from '@/components/reports/report-status-badge';
import { apiFetch } from '@/lib/api/client';
interface GeneratedReport {
id: string;
name: string;
reportType: string;
status: 'queued' | 'processing' | 'ready' | 'failed';
requestedBy: string;
createdAt: string;
completedAt: string | null;
errorMessage: string | null;
fileId: string | null;
}
interface ReportsResponse {
data: GeneratedReport[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
}
const REPORT_TYPE_LABELS: Record<string, string> = {
pipeline: 'Pipeline Summary',
revenue: 'Revenue',
activity: 'Activity Log',
occupancy: 'Berth Occupancy',
};
export function ReportsList() {
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const { data, isLoading } = useQuery<ReportsResponse>({
queryKey: ['reports'],
queryFn: () => apiFetch<ReportsResponse>('/api/v1/reports?limit=50'),
refetchInterval: (query) => {
const rows = query.state.data?.data ?? [];
const hasPending = rows.some(
(r) => r.status === 'queued' || r.status === 'processing',
);
return hasPending ? 5000 : false;
},
});
const handleDownload = async (reportId: string) => {
setDownloadingId(reportId);
try {
const result = await apiFetch<{ url: string }>(
`/api/v1/reports/${reportId}/download`,
);
window.open(result.url, '_blank');
} catch (err) {
console.error('Download failed', err);
} finally {
setDownloadingId(null);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Generated Reports</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
) : !data?.data.length ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">
No reports generated yet
</p>
<p className="text-xs text-muted-foreground">
Use the form above to generate your first report.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Requested</TableHead>
<TableHead>Completed</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.data.map((report) => (
<TableRow key={report.id}>
<TableCell className="font-medium">{report.name}</TableCell>
<TableCell className="text-muted-foreground">
{REPORT_TYPE_LABELS[report.reportType] ?? report.reportType}
</TableCell>
<TableCell>
<ReportStatusBadge status={report.status} />
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{new Date(report.createdAt).toLocaleString('en-GB', {
dateStyle: 'short',
timeStyle: 'short',
})}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{report.completedAt
? new Date(report.completedAt).toLocaleString('en-GB', {
dateStyle: 'short',
timeStyle: 'short',
})
: report.status === 'failed' && report.errorMessage
? (
<span className="text-destructive text-xs" title={report.errorMessage}>
Failed
</span>
)
: '—'}
</TableCell>
<TableCell className="text-right">
{report.status === 'ready' && report.fileId && (
<Button
variant="outline"
size="sm"
onClick={() => handleDownload(report.id)}
disabled={downloadingId === report.id}
>
<Download className="mr-1 h-4 w-4" />
{downloadingId === report.id ? 'Opening...' : 'Download'}
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
import { GenerateReportForm } from '@/components/reports/generate-report-form';
import { ReportsList } from '@/components/reports/reports-list';
export function ReportsPageClient() {
useRealtimeInvalidation({
'report:queued': [['reports']],
'report:ready': [['reports']],
'report:failed': [['reports']],
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Reports</h1>
<p className="text-muted-foreground">
Generate and download port reports as PDF documents
</p>
</div>
<GenerateReportForm />
<ReportsList />
</div>
);
}

View File

@@ -0,0 +1,222 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Search, Clock, User, TrendingUp, Anchor } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSearch } from '@/hooks/use-search';
import { useUIStore } from '@/stores/ui-store';
export function CommandSearch() {
const [focused, setFocused] = useState(false);
const [query, setQuery] = useState('');
const router = useRouter();
const portSlug = useUIStore((s) => s.currentPortSlug);
const wrapperRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { results, isLoading, recentSearches } = useSearch(query);
const showDropdown = focused && (query.length > 0 || recentSearches.length > 0);
const hasQuery = query.length >= 2;
const hasResults =
results &&
(results.clients.length > 0 || results.interests.length > 0 || results.berths.length > 0);
// Cmd/Ctrl+K focuses the input
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
inputRef.current?.focus();
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
// Click outside closes dropdown
useEffect(() => {
if (!focused) return;
function onClick(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setFocused(false);
}
}
document.addEventListener('mousedown', onClick);
return () => document.removeEventListener('mousedown', onClick);
}, [focused]);
const navigate = useCallback(
(path: string) => {
setFocused(false);
setQuery('');
inputRef.current?.blur();
router.push(path as any);
},
[router],
);
// Keyboard nav inside dropdown
function onInputKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Escape') {
setFocused(false);
inputRef.current?.blur();
}
}
const iconMap = { client: User, interest: TrendingUp, berth: Anchor } as const;
return (
<div ref={wrapperRef} className="relative">
{/* ── Single persistent search bar ── */}
<div
className={cn(
'flex items-center gap-2 rounded-md border bg-background px-2.5 transition-all duration-150',
focused ? 'border-muted-foreground/40 w-64 lg:w-80' : 'w-44 lg:w-60',
)}
>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => setFocused(true)}
onKeyDown={onInputKeyDown}
placeholder="Search..."
className="h-8 flex-1 min-w-0 bg-transparent text-sm outline-none ring-0 focus:outline-none focus:ring-0 placeholder:text-muted-foreground"
/>
</div>
{/* ── Results dropdown ── */}
{showDropdown && (
<div className="absolute top-[calc(100%+4px)] left-0 w-[min(420px,calc(100vw-2rem))] z-50 rounded-md border bg-popover shadow-lg overflow-hidden">
<div className="max-h-[340px] overflow-y-auto py-1">
{/* No query yet — show recent or hint */}
{!hasQuery && recentSearches.length > 0 && (
<div>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">Recent</div>
{recentSearches.map((term) => (
<button
key={term}
onClick={() => setQuery(term)}
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent cursor-pointer"
>
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
{term}
</button>
))}
</div>
)}
{!hasQuery && recentSearches.length === 0 && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</div>
)}
{/* Loading */}
{hasQuery && isLoading && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Searching...
</div>
)}
{/* No results */}
{hasQuery && !isLoading && !hasResults && (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
No results for &ldquo;{query}&rdquo;
</div>
)}
{/* Result groups */}
{hasQuery && !isLoading && results && (
<>
{results.clients.length > 0 && (
<ResultGroup
heading="Clients"
items={results.clients.map((c) => ({
id: c.id,
icon: 'client',
label: c.fullName,
sub: c.companyName,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/clients/${id}`)}
/>
)}
{results.interests.length > 0 && (
<ResultGroup
heading="Interests"
items={results.interests.map((i) => ({
id: i.id,
icon: 'interest',
label: i.clientName,
sub: i.berthMooringNumber ?? i.pipelineStage,
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/interests/${id}`)}
/>
)}
{results.berths.length > 0 && (
<ResultGroup
heading="Berths"
items={results.berths.map((b) => ({
id: b.id,
icon: 'berth',
label: b.mooringNumber,
sub: [b.area, b.status].filter(Boolean).join(' · '),
}))}
iconMap={iconMap}
onSelect={(id) => navigate(`/${portSlug}/berths/${id}`)}
/>
)}
</>
)}
</div>
</div>
)}
</div>
);
}
function ResultGroup({
heading,
items,
iconMap,
onSelect,
}: {
heading: string;
items: Array<{ id: string; icon: 'client' | 'interest' | 'berth'; label: string; sub?: string | null }>;
iconMap: Record<string, React.ElementType>;
onSelect: (id: string) => void;
}) {
return (
<div>
<div className="px-3 py-1.5 text-xs font-medium text-muted-foreground">{heading}</div>
{items.map((item) => {
const Icon = iconMap[item.icon];
return (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className="flex w-full items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent cursor-pointer text-left"
>
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{item.label}</span>
{item.sub && (
<span className="ml-auto truncate text-xs text-muted-foreground">{item.sub}</span>
)}
</button>
);
})}
</div>
);
}
// Keep export for backwards compat — it's a no-op
export function SearchTrigger() {
return null;
}

View File

@@ -0,0 +1,78 @@
'use client';
import { User, Anchor, TrendingUp } from 'lucide-react';
import { CommandItem } from '@/components/ui/command';
// ─── Types ────────────────────────────────────────────────────────────────────
interface ClientItem {
id: string;
fullName: string;
companyName: string | null;
}
interface InterestItem {
id: string;
clientName: string;
berthMooringNumber: string | null;
pipelineStage: string;
}
interface BerthItem {
id: string;
mooringNumber: string;
area: string | null;
status: string;
}
type SearchResultItemProps =
| { type: 'client'; item: ClientItem; onSelect: () => void }
| { type: 'interest'; item: InterestItem; onSelect: () => void }
| { type: 'berth'; item: BerthItem; onSelect: () => void };
// ─── Component ────────────────────────────────────────────────────────────────
export function SearchResultItem({ type, item, onSelect }: SearchResultItemProps) {
if (type === 'client') {
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<User className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.fullName}</span>
{item.companyName && (
<span className="text-xs text-muted-foreground">{item.companyName}</span>
)}
</div>
</CommandItem>
);
}
if (type === 'interest') {
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.clientName}</span>
<span className="text-xs text-muted-foreground">
{item.berthMooringNumber ? `${item.berthMooringNumber} · ` : ''}
{item.pipelineStage}
</span>
</div>
</CommandItem>
);
}
// berth
return (
<CommandItem value={item.id} onSelect={onSelect} className="flex items-center gap-2">
<Anchor className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col">
<span className="text-sm font-medium">{item.mooringNumber}</span>
<span className="text-xs text-muted-foreground">
{[item.area, item.status].filter(Boolean).join(' · ')}
</span>
</div>
</CommandItem>
);
}

View File

@@ -0,0 +1,66 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Loader2 } from 'lucide-react';
interface ArchiveConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
entityName: string;
entityType: string;
isArchived: boolean;
onConfirm: () => void;
isLoading?: boolean;
}
export function ArchiveConfirmDialog({
open,
onOpenChange,
entityName,
entityType,
isArchived,
onConfirm,
isLoading,
}: ArchiveConfirmDialogProps) {
const action = isArchived ? 'Restore' : 'Archive';
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{action} {entityType}?
</AlertDialogTitle>
<AlertDialogDescription>
{isArchived
? `Are you sure you want to restore "${entityName}"? This ${entityType.toLowerCase()} will be visible in the default list again.`
: `Are you sure you want to archive "${entityName}"? This ${entityType.toLowerCase()} will be hidden from the default list but can be restored later.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
onConfirm();
}}
disabled={isLoading}
className={isArchived ? '' : 'bg-destructive text-destructive-foreground hover:bg-destructive/90'}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{action}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,72 @@
'use client';
import { type ReactNode } from 'react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
interface ConfirmationDialogProps {
/** The element that triggers the dialog to open */
trigger: ReactNode;
title: string;
description: string;
/** Label for the confirm action button (default: "Delete") */
confirmLabel?: string;
/** Label for the cancel button (default: "Cancel") */
cancelLabel?: string;
/** Whether the confirm action is destructive — renders in red (default: true) */
destructive?: boolean;
/** Called when the user confirms the action */
onConfirm: () => void | Promise<void>;
/** Whether the confirm button is in a loading state */
loading?: boolean;
}
/**
* Reusable confirmation dialog for destructive actions (delete, archive, etc.).
* Wraps shadcn AlertDialog so the trigger can be any element.
*/
export function ConfirmationDialog({
trigger,
title,
description,
confirmLabel = 'Delete',
cancelLabel = 'Cancel',
destructive = true,
onConfirm,
loading = false,
}: ConfirmationDialogProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={loading}
className={cn(
destructive &&
'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus:ring-destructive',
)}
>
{loading ? 'Please wait...' : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,332 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { apiFetch } from '@/lib/api/client';
// ─── Types ────────────────────────────────────────────────────────────────────
interface CustomFieldDefinition {
id: string;
fieldName: string;
fieldLabel: string;
fieldType: 'text' | 'number' | 'date' | 'boolean' | 'select';
selectOptions: string[] | null;
isRequired: boolean;
sortOrder: number;
entityType: string;
}
interface CustomFieldValue {
id: string;
fieldId: string;
entityId: string;
value: unknown;
}
interface FieldEntry {
definition: CustomFieldDefinition;
value: CustomFieldValue | null;
}
interface CustomFieldsSectionProps {
entityType: 'client' | 'interest' | 'berth';
entityId: string;
}
// ─── Component ────────────────────────────────────────────────────────────────
export function CustomFieldsSection({ entityType, entityId }: CustomFieldsSectionProps) {
const [collapsed, setCollapsed] = useState(false);
const queryClient = useQueryClient();
// ── Data fetching ──────────────────────────────────────────────────────────
const { data: entries, isLoading } = useQuery<FieldEntry[]>({
queryKey: ['custom-field-values', entityId],
queryFn: async () => {
const res = await apiFetch<{ data: FieldEntry[] }>(
`/api/v1/custom-fields/${entityId}`,
);
return res.data;
},
enabled: !!entityId,
});
// Only show fields for this entity type
const filteredEntries =
entries?.filter((e) => e.definition.entityType === entityType) ?? [];
// ── Mutation ───────────────────────────────────────────────────────────────
const mutation = useMutation({
mutationFn: async (values: Array<{ fieldId: string; value: unknown }>) => {
await apiFetch(`/api/v1/custom-fields/${entityId}`, {
method: 'PUT',
body: { values },
});
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['custom-field-values', entityId] });
},
});
if (isLoading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Custom Fields</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2].map((i) => (
<div key={i} className="h-8 animate-pulse rounded bg-muted" />
))}
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setCollapsed((c) => !c)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Custom Fields</CardTitle>
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
</CardHeader>
{!collapsed && (
<CardContent>
{filteredEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom fields configured.</p>
) : (
<div className="space-y-4">
{filteredEntries.map((entry) => (
<FieldControl
key={entry.definition.id}
entry={entry}
onSave={(fieldId, value) =>
mutation.mutate([{ fieldId, value }])
}
/>
))}
</div>
)}
</CardContent>
)}
</Card>
);
}
// ─── FieldControl ─────────────────────────────────────────────────────────────
interface FieldControlProps {
entry: FieldEntry;
onSave: (fieldId: string, value: unknown) => void;
}
function FieldControl({ entry, onSave }: FieldControlProps) {
const { definition, value: savedValue } = entry;
const initialValue = savedValue?.value ?? null;
// Debounce timer ref
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
function scheduleBlurSave(fieldId: string, val: unknown) {
// Immediate debounce cancel then save after 500ms idle
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
onSave(fieldId, val);
}, 500);
}
const label = (
<Label htmlFor={`cf-${definition.id}`} className="text-sm font-medium">
{definition.fieldLabel}
{definition.isRequired && (
<span className="ml-0.5 text-destructive" aria-label="required">
*
</span>
)}
</Label>
);
if (definition.fieldType === 'boolean') {
return (
<BooleanField
definition={definition}
initialValue={initialValue as boolean | null}
label={label}
onSave={onSave}
/>
);
}
if (definition.fieldType === 'select') {
return (
<SelectField
definition={definition}
initialValue={initialValue as string | null}
label={label}
onSave={onSave}
/>
);
}
// text / number / date
return (
<TextLikeField
definition={definition}
initialValue={initialValue}
label={label}
onScheduleSave={scheduleBlurSave}
/>
);
}
// ─── Sub-controls ──────────────────────────────────────────────────────────────
function TextLikeField({
definition,
initialValue,
label,
onScheduleSave,
}: {
definition: CustomFieldDefinition;
initialValue: unknown;
label: React.ReactNode;
onScheduleSave: (fieldId: string, val: unknown) => void;
}) {
const [localValue, setLocalValue] = useState(
initialValue !== null && initialValue !== undefined ? String(initialValue) : '',
);
const inputType =
definition.fieldType === 'number'
? 'number'
: definition.fieldType === 'date'
? 'date'
: 'text';
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
setLocalValue(raw);
let parsed: unknown = raw;
if (definition.fieldType === 'number') {
parsed = raw === '' ? null : parseFloat(raw);
} else if (raw === '') {
parsed = null;
}
onScheduleSave(definition.id, parsed);
}
return (
<div className="space-y-1.5">
{label}
<Input
id={`cf-${definition.id}`}
type={inputType}
value={localValue}
onChange={handleChange}
placeholder={definition.fieldLabel}
/>
</div>
);
}
function BooleanField({
definition,
initialValue,
label,
onSave,
}: {
definition: CustomFieldDefinition;
initialValue: boolean | null;
label: React.ReactNode;
onSave: (fieldId: string, val: unknown) => void;
}) {
const [checked, setChecked] = useState(initialValue ?? false);
function handleChange(val: boolean) {
setChecked(val);
onSave(definition.id, val);
}
return (
<div className="flex items-center justify-between gap-2">
{label}
<Switch
id={`cf-${definition.id}`}
checked={checked}
onCheckedChange={handleChange}
/>
</div>
);
}
function SelectField({
definition,
initialValue,
label,
onSave,
}: {
definition: CustomFieldDefinition;
initialValue: string | null;
label: React.ReactNode;
onSave: (fieldId: string, val: unknown) => void;
}) {
const options = definition.selectOptions ?? [];
const [selected, setSelected] = useState(initialValue ?? '');
function handleChange(val: string) {
setSelected(val);
onSave(definition.id, val === '__none__' ? null : val);
}
return (
<div className="space-y-1.5">
{label}
<Select value={selected || '__none__'} onValueChange={handleChange}>
<SelectTrigger id={`cf-${definition.id}`}>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{!definition.isRequired && (
<SelectItem value="__none__">
<span className="text-muted-foreground">None</span>
</SelectItem>
)}
{options.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,274 @@
'use client';
import { useState } from 'react';
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type SortingState,
type RowSelectionState,
type PaginationState,
} from '@tanstack/react-table';
import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { cn } from '@/lib/utils';
export interface DataTablePagination {
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface BulkAction {
label: string;
icon?: React.ElementType;
variant?: 'default' | 'destructive';
onClick: (selectedIds: string[]) => void;
}
interface DataTableProps<TData> {
columns: ColumnDef<TData, unknown>[];
data: TData[];
pagination?: DataTablePagination;
onPaginationChange?: (page: number, pageSize: number) => void;
sort?: { field: string; direction: 'asc' | 'desc' };
onSortChange?: (field: string, direction: 'asc' | 'desc') => void;
rowSelection?: RowSelectionState;
onRowSelectionChange?: (selection: RowSelectionState) => void;
bulkActions?: BulkAction[];
emptyState?: React.ReactNode;
isLoading?: boolean;
getRowId?: (row: TData) => string;
onRowClick?: (row: TData) => void;
}
export function DataTable<TData>({
columns,
data,
pagination,
onPaginationChange,
sort,
onSortChange,
rowSelection: externalSelection,
onRowSelectionChange,
bulkActions,
emptyState,
isLoading,
getRowId,
onRowClick,
}: DataTableProps<TData>) {
const [internalSelection, setInternalSelection] = useState<RowSelectionState>({});
const rowSelectionState = externalSelection ?? internalSelection;
const setRowSelection = onRowSelectionChange ?? setInternalSelection;
const allColumns: ColumnDef<TData, unknown>[] = [];
if (bulkActions && bulkActions.length > 0) {
allColumns.push({
id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
size: 40,
});
}
allColumns.push(...columns);
const table = useReactTable({
data,
columns: allColumns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualSorting: true,
rowCount: pagination?.total ?? data.length,
state: {
rowSelection: rowSelectionState,
pagination: pagination
? { pageIndex: pagination.page - 1, pageSize: pagination.pageSize }
: undefined,
},
onRowSelectionChange: (updater) => {
const newSelection =
typeof updater === 'function' ? updater(rowSelectionState) : updater;
setRowSelection(newSelection);
},
getRowId: getRowId as (row: TData, index: number) => string,
enableRowSelection: !!bulkActions?.length,
});
const selectedIds = Object.keys(rowSelectionState).filter(
(k) => rowSelectionState[k],
);
function handleSort(columnId: string) {
if (!onSortChange) return;
if (sort?.field === columnId) {
onSortChange(columnId, sort.direction === 'asc' ? 'desc' : 'asc');
} else {
onSortChange(columnId, 'asc');
}
}
function getSortIcon(columnId: string) {
if (sort?.field !== columnId) return <ArrowUpDown className="ml-1 h-3.5 w-3.5" />;
return sort.direction === 'asc' ? (
<ArrowUp className="ml-1 h-3.5 w-3.5" />
) : (
<ArrowDown className="ml-1 h-3.5 w-3.5" />
);
}
return (
<div className="space-y-2">
<div className="rounded-md border">
<Table>
<TableHeader className="sticky top-0 z-10 bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() !== 150 ? header.getSize() : undefined }}
className={cn(
header.column.getCanSort() && onSortChange && 'cursor-pointer select-none',
)}
onClick={() => {
if (header.column.getCanSort() && onSortChange && header.column.id !== 'select') {
handleSort(header.column.id);
}
}}
>
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() &&
onSortChange &&
header.column.id !== 'select' &&
header.column.id !== 'actions' &&
getSortIcon(header.column.id)}
</div>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={allColumns.length} className="h-40 text-center">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={allColumns.length} className="h-40">
{emptyState ?? (
<div className="text-center text-muted-foreground">No results.</div>
)}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className={cn(onRowClick && 'cursor-pointer')}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
{pagination && pagination.totalPages > 1 && (
<div className="flex items-center justify-between px-2">
<div className="text-sm text-muted-foreground">
{selectedIds.length > 0
? `${selectedIds.length} of ${pagination.total} row(s) selected`
: `${pagination.total} row(s) total`}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={pagination.page <= 1}
onClick={() => onPaginationChange?.(pagination.page - 1, pagination.pageSize)}
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
Page {pagination.page} of {pagination.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={pagination.page >= pagination.totalPages}
onClick={() => onPaginationChange?.(pagination.page + 1, pagination.pageSize)}
>
Next
</Button>
</div>
</div>
)}
{/* Bulk actions bar */}
{bulkActions && selectedIds.length > 0 && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-lg border bg-background px-4 py-3 shadow-lg animate-in slide-in-from-bottom-4">
<span className="text-sm font-medium">{selectedIds.length} selected</span>
{bulkActions.map((action) => (
<Button
key={action.label}
variant={action.variant ?? 'default'}
size="sm"
onClick={() => action.onClick(selectedIds)}
>
{action.icon && <action.icon className="mr-1.5 h-4 w-4" />}
{action.label}
</Button>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
'use client';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { Loader2 } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
export interface DetailTab {
id: string;
label: string;
content: React.ReactNode;
badge?: string | number;
}
interface DetailLayoutProps {
header: React.ReactNode;
tabs: DetailTab[];
defaultTab?: string;
isLoading?: boolean;
actions?: React.ReactNode;
}
export function DetailLayout({
header,
tabs,
defaultTab,
isLoading,
actions,
}: DetailLayoutProps) {
const searchParams = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const activeTab = searchParams.get('tab') ?? defaultTab ?? tabs[0]?.id;
function handleTabChange(tabId: string) {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tabId);
router.replace(`${pathname}?${params.toString()}` as any, { scroll: false });
}
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">{header}</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
{tabs.map((tab) => (
<TabsTrigger key={tab.id} value={tab.id} className="gap-1.5">
{tab.label}
{tab.badge !== undefined && tab.badge !== null && (
<Badge variant="secondary" className="px-1.5 py-0 text-xs">
{tab.badge}
</Badge>
)}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4">
{tab.content}
</TabsContent>
))}
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import { type ReactNode, type ElementType } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface EmptyStateProps {
icon?: ElementType;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
className?: string;
}
/**
* Centered empty-state pattern with icon, title, description, and optional CTA.
* Used when a list or table has no data.
*/
export function EmptyState({ icon: Icon, title, description, action, className }: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center text-center py-16 px-4',
className,
)}
>
{Icon && (
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-muted-foreground" />
</div>
)}
<h3 className="text-base font-semibold text-foreground mb-1">{title}</h3>
{description && (
<p className="text-sm text-muted-foreground max-w-sm mb-4">{description}</p>
)}
{action && (
<Button onClick={action.onClick} size="sm">
{action.label}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,266 @@
'use client';
import { X, Filter, ChevronDown } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
export type FilterType =
| 'text'
| 'select'
| 'multi-select'
| 'date-range'
| 'boolean'
| 'relation';
export interface FilterOption {
label: string;
value: string;
}
export interface FilterDefinition {
key: string;
label: string;
type: FilterType;
options?: FilterOption[];
placeholder?: string;
}
export type FilterValues = Record<string, unknown>;
interface FilterBarProps {
filters: FilterDefinition[];
values: FilterValues;
onChange: (key: string, value: unknown) => void;
onClear: () => void;
}
/**
* Serializes filter values to URL search params.
*/
export function serializeFiltersToParams(values: FilterValues): URLSearchParams {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(values)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length > 0) params.set(key, value.join(','));
} else {
params.set(key, String(value));
}
}
return params;
}
/**
* Deserializes URL search params into filter values.
*/
export function deserializeFiltersFromParams(
params: URLSearchParams,
definitions: FilterDefinition[],
): FilterValues {
const values: FilterValues = {};
for (const def of definitions) {
const raw = params.get(def.key);
if (!raw) continue;
if (def.type === 'multi-select') {
values[def.key] = raw.split(',');
} else if (def.type === 'boolean') {
values[def.key] = raw === 'true';
} else {
values[def.key] = raw;
}
}
return values;
}
function getActiveFilterCount(values: FilterValues): number {
let count = 0;
for (const value of Object.values(values)) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value) && value.length === 0) continue;
count++;
}
return count;
}
export function FilterBar({ filters, values, onChange, onClear }: FilterBarProps) {
const activeCount = getActiveFilterCount(values);
return (
<div className="flex items-center gap-2 flex-wrap">
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Filter className="mr-1.5 h-3.5 w-3.5" />
Filters
{activeCount > 0 && (
<Badge variant="secondary" className="ml-1.5 px-1.5 py-0 text-xs">
{activeCount}
</Badge>
)}
<ChevronDown className="ml-1 h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-4" align="start">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Filters</h4>
{activeCount > 0 && (
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onClear}>
Clear all
</Button>
)}
</div>
{filters.map((filter) => (
<FilterField
key={filter.key}
definition={filter}
value={values[filter.key]}
onChange={(val) => onChange(filter.key, val)}
/>
))}
</div>
</PopoverContent>
</Popover>
{/* Active filter chips */}
{activeCount > 0 &&
filters.map((filter) => {
const value = values[filter.key];
if (value === undefined || value === null || value === '') return null;
if (Array.isArray(value) && value.length === 0) return null;
const displayValue = Array.isArray(value)
? `${value.length} selected`
: typeof value === 'boolean'
? value ? 'Yes' : 'No'
: filter.options?.find((o) => o.value === String(value))?.label ?? String(value);
return (
<Badge key={filter.key} variant="secondary" className="h-7 gap-1 px-2 font-normal">
{filter.label}: {displayValue}
<button
className="ml-1 rounded-full hover:bg-muted-foreground/20"
onClick={() => onChange(filter.key, undefined)}
>
<X className="h-3 w-3" />
</button>
</Badge>
);
})}
</div>
);
}
function FilterField({
definition,
value,
onChange,
}: {
definition: FilterDefinition;
value: unknown;
onChange: (value: unknown) => void;
}) {
switch (definition.type) {
case 'text':
return (
<div className="space-y-1">
<Label className="text-xs">{definition.label}</Label>
<Input
placeholder={definition.placeholder ?? `Filter by ${definition.label.toLowerCase()}...`}
value={(value as string) ?? ''}
onChange={(e) => onChange(e.target.value || undefined)}
className="h-8"
/>
</div>
);
case 'select':
return (
<div className="space-y-1">
<Label className="text-xs">{definition.label}</Label>
<Select
value={(value as string) ?? ''}
onValueChange={(v) => onChange(v || undefined)}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={definition.placeholder ?? 'Any'} />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any</SelectItem>
{definition.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
case 'multi-select':
return (
<div className="space-y-2">
<Label className="text-xs">{definition.label}</Label>
<div className="max-h-32 overflow-y-auto space-y-1">
{definition.options?.map((opt) => {
const selected = Array.isArray(value) ? value.includes(opt.value) : false;
return (
<div key={opt.value} className="flex items-center gap-2">
<Checkbox
id={`${definition.key}-${opt.value}`}
checked={selected}
onCheckedChange={(checked) => {
const current = Array.isArray(value) ? value : [];
const next = checked
? [...current, opt.value]
: current.filter((v: string) => v !== opt.value);
onChange(next.length > 0 ? next : undefined);
}}
/>
<label
htmlFor={`${definition.key}-${opt.value}`}
className="text-sm cursor-pointer"
>
{opt.label}
</label>
</div>
);
})}
</div>
</div>
);
case 'boolean':
return (
<div className="flex items-center gap-2">
<Checkbox
id={definition.key}
checked={value === true}
onCheckedChange={(checked) => onChange(checked || undefined)}
/>
<label htmlFor={definition.key} className="text-sm cursor-pointer">
{definition.label}
</label>
</div>
);
default:
return null;
}
}

View File

@@ -0,0 +1,112 @@
import { cn } from '@/lib/utils';
import { Skeleton } from '@/components/ui/skeleton';
interface LoadingSkeletonProps {
className?: string;
}
/**
* Table skeleton — mimics a data table with header + rows.
*/
export function TableSkeleton({ rows = 6, columns = 5 }: { rows?: number; columns?: number }) {
return (
<div className="w-full space-y-0 border border-border rounded-lg overflow-hidden">
{/* Header row */}
<div className="flex gap-4 px-4 py-3 bg-muted border-b border-border">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className={cn('h-4', i === 0 ? 'w-1/4' : 'flex-1')} />
))}
</div>
{/* Data rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div
key={rowIdx}
className={cn(
'flex gap-4 px-4 py-3.5 border-b border-border last:border-0',
rowIdx % 2 === 0 ? 'bg-background' : 'bg-muted/20',
)}
>
{Array.from({ length: columns }).map((_, colIdx) => (
<Skeleton
key={colIdx}
className={cn('h-4', colIdx === 0 ? 'w-1/4' : 'flex-1')}
/>
))}
</div>
))}
</div>
);
}
/**
* Card skeleton — mimics a content card.
*/
export function CardSkeleton({ className }: LoadingSkeletonProps) {
return (
<div className={cn('border border-border rounded-lg p-5 space-y-3 bg-background', className)}>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<div className="pt-2 flex gap-2">
<Skeleton className="h-8 w-20 rounded-md" />
<Skeleton className="h-8 w-20 rounded-md" />
</div>
</div>
);
}
/**
* Form skeleton — mimics a form with labeled inputs.
*/
export function FormSkeleton({ fields = 4 }: { fields?: number }) {
return (
<div className="space-y-5">
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-1.5">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-9 w-full rounded-md" />
</div>
))}
<div className="flex gap-3 pt-2">
<Skeleton className="h-9 w-24 rounded-md" />
<Skeleton className="h-9 w-20 rounded-md" />
</div>
</div>
);
}
/**
* Grid skeleton — a responsive card grid.
*/
export function GridSkeleton({ cards = 6 }: { cards?: number }) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: cards }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
/**
* Page-level loading skeleton — header + content area.
*/
export function PageSkeleton() {
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div className="space-y-1.5">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-72" />
</div>
<Skeleton className="h-9 w-28 rounded-md" />
</div>
{/* Content */}
<TableSkeleton />
</div>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { formatDistanceToNow } from 'date-fns';
import { Lock, Pencil, Trash2, Send, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { apiFetch } from '@/lib/api/client';
interface Note {
id: string;
content: string;
authorId: string;
authorName?: string;
isLocked: boolean;
createdAt: string;
updatedAt: string;
}
interface NotesListProps {
entityType: 'clients' | 'interests';
entityId: string;
currentUserId?: string;
}
const NOTE_EDIT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
export function NotesList({ entityType, entityId, currentUserId }: NotesListProps) {
const queryClient = useQueryClient();
const [newNote, setNewNote] = useState('');
const [editingId, setEditingId] = useState<string | null>(null);
const [editContent, setEditContent] = useState('');
const endpoint = `/api/v1/${entityType}/${entityId}/notes`;
const queryKey = [entityType, entityId, 'notes'];
const { data: notes = [], isLoading } = useQuery<Note[]>({
queryKey,
queryFn: () => apiFetch<{ data: Note[] }>(endpoint).then((r) => r.data),
});
const createMutation = useMutation({
mutationFn: (content: string) =>
apiFetch(endpoint, { method: 'POST', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setNewNote('');
},
});
const updateMutation = useMutation({
mutationFn: ({ noteId, content }: { noteId: string; content: string }) =>
apiFetch(`${endpoint}/${noteId}`, { method: 'PATCH', body: { content } }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });
setEditingId(null);
},
});
const deleteMutation = useMutation({
mutationFn: (noteId: string) =>
apiFetch(`${endpoint}/${noteId}`, { method: 'DELETE' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
function canEdit(note: Note): boolean {
if (note.authorId !== currentUserId) return false;
if (note.isLocked) return false;
const elapsed = Date.now() - new Date(note.createdAt).getTime();
return elapsed < NOTE_EDIT_WINDOW_MS;
}
function getTimeRemaining(note: Note): string | null {
const elapsed = Date.now() - new Date(note.createdAt).getTime();
const remaining = NOTE_EDIT_WINDOW_MS - elapsed;
if (remaining <= 0) return null;
const mins = Math.ceil(remaining / 60000);
return `${mins}m left to edit`;
}
return (
<div className="space-y-4">
{/* Create note form */}
<div className="space-y-2">
<Textarea
placeholder="Add a note..."
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
rows={3}
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!newNote.trim() || createMutation.isPending}
onClick={() => createMutation.mutate(newNote.trim())}
>
{createMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Send className="mr-1.5 h-4 w-4" />
)}
Add Note
</Button>
</div>
</div>
{/* Notes list */}
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading notes...</div>
) : notes.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">No notes yet</div>
) : (
<div className="space-y-3">
{notes.map((note) => (
<div key={note.id} className="flex gap-3 p-3 rounded-lg border bg-card">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="text-xs">
{(note.authorName ?? 'U').charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{note.authorName ?? 'User'}</span>
<span className="text-muted-foreground">
{formatDistanceToNow(new Date(note.createdAt), { addSuffix: true })}
</span>
{note.isLocked && (
<Lock className="h-3 w-3 text-muted-foreground" />
)}
{canEdit(note) && (
<span className="text-xs text-muted-foreground">
{getTimeRemaining(note)}
</span>
)}
</div>
{editingId === note.id ? (
<div className="space-y-2">
<Textarea
value={editContent}
onChange={(e) => setEditContent(e.target.value)}
rows={3}
/>
<div className="flex gap-2">
<Button
size="sm"
disabled={!editContent.trim() || updateMutation.isPending}
onClick={() =>
updateMutation.mutate({ noteId: note.id, content: editContent.trim() })
}
>
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingId(null)}>
Cancel
</Button>
</div>
</div>
) : (
<p className="text-sm whitespace-pre-wrap">{note.content}</p>
)}
</div>
{canEdit(note) && editingId !== note.id && (
<div className="flex gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => {
setEditingId(note.id);
setEditContent(note.content);
}}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => deleteMutation.mutate(note.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { type ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
className?: string;
}
/**
* Consistent page-level header: title, optional description, and an action
* slot (typically buttons — e.g. "New Client", "Export").
*/
export function PageHeader({ title, description, actions, className }: PageHeaderProps) {
return (
<div className={cn('flex items-start justify-between gap-4 mb-6', className)}>
<div className="min-w-0">
<h1 className="text-2xl font-bold text-foreground tracking-tight truncate">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,30 @@
'use client';
import type { ReactNode } from 'react';
import { usePermissions } from '@/hooks/use-permissions';
import type { RolePermissions } from '@/lib/db/schema/users';
type Resource = keyof RolePermissions;
type Action<R extends Resource> = keyof RolePermissions[R];
interface PermissionGateProps<R extends Resource> {
resource: R;
action: Action<R>;
children: ReactNode;
fallback?: ReactNode;
}
export function PermissionGate<R extends Resource>({
resource,
action,
children,
fallback = null,
}: PermissionGateProps<R>) {
const { can } = usePermissions();
if (!can(resource, action)) {
return <>{fallback}</>;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,135 @@
'use client';
import { useState } from 'react';
import { Bookmark, Check, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useSavedViews } from '@/hooks/use-saved-views';
interface SavedViewsDropdownProps {
entityType: string;
currentFilters: Record<string, unknown>;
currentSort?: { field: string; direction: 'asc' | 'desc' };
onApplyView: (filters: Record<string, unknown>, sort?: { field: string; direction: string }) => void;
}
export function SavedViewsDropdown({
entityType,
currentFilters,
currentSort,
onApplyView,
}: SavedViewsDropdownProps) {
const { views, activeViewId, saveCurrentView, deleteView, applyView } =
useSavedViews(entityType);
const [saveOpen, setSaveOpen] = useState(false);
const [viewName, setViewName] = useState('');
const [isSaving, setIsSaving] = useState(false);
async function handleSave() {
if (!viewName.trim()) return;
setIsSaving(true);
try {
await saveCurrentView(viewName.trim(), currentFilters, currentSort);
setSaveOpen(false);
setViewName('');
} finally {
setIsSaving(false);
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Bookmark className="mr-1.5 h-3.5 w-3.5" />
Views
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
{views.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground text-center">
No saved views yet
</div>
) : (
views.map((view) => (
<DropdownMenuItem
key={view.id}
className="flex items-center justify-between"
onClick={() => {
applyView(view.id);
onApplyView(
view.filters as Record<string, unknown>,
view.sortConfig as { field: string; direction: string } | undefined,
);
}}
>
<span className="truncate">{view.name}</span>
<div className="flex items-center gap-1">
{activeViewId === view.id && (
<Check className="h-3.5 w-3.5 text-primary" />
)}
<button
className="p-0.5 rounded hover:bg-muted"
onClick={(e) => {
e.stopPropagation();
deleteView(view.id);
}}
>
<Trash2 className="h-3 w-3 text-muted-foreground" />
</button>
</div>
</DropdownMenuItem>
))
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSaveOpen(true)}>
<Plus className="mr-2 h-3.5 w-3.5" />
Save current view
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={saveOpen} onOpenChange={setSaveOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Save View</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>View name</Label>
<Input
value={viewName}
onChange={(e) => setViewName(e.target.value)}
placeholder="My custom view"
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSaveOpen(false)}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!viewName.trim() || isSaving}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,25 @@
import { cn } from '@/lib/utils';
interface TagBadgeProps {
name: string;
color: string;
className?: string;
}
export function TagBadge({ name, color, className }: TagBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
'bg-muted text-foreground',
className,
)}
>
<span
className="h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
{name}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More