Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
343
src/components/admin/custom-fields/custom-field-form.tsx
Normal file
343
src/components/admin/custom-fields/custom-field-form.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CustomFieldDefinition {
|
||||
id: string;
|
||||
entityType: string;
|
||||
fieldName: string;
|
||||
fieldLabel: string;
|
||||
fieldType: string;
|
||||
selectOptions: string[] | null;
|
||||
isRequired: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
interface CustomFieldFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
field?: CustomFieldDefinition | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||
client: 'Clients',
|
||||
interest: 'Interests',
|
||||
berth: 'Berths',
|
||||
};
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
date: 'Date',
|
||||
boolean: 'Yes / No',
|
||||
select: 'Dropdown (Select)',
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CustomFieldForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
field,
|
||||
onSuccess,
|
||||
}: CustomFieldFormProps) {
|
||||
const isEdit = !!field;
|
||||
|
||||
// Form state
|
||||
const [entityType, setEntityType] = useState(field?.entityType ?? 'client');
|
||||
const [fieldName, setFieldName] = useState(field?.fieldName ?? '');
|
||||
const [fieldLabel, setFieldLabel] = useState(field?.fieldLabel ?? '');
|
||||
const [fieldType, setFieldType] = useState(field?.fieldType ?? 'text');
|
||||
const [selectOptions, setSelectOptions] = useState<string[]>(
|
||||
field?.selectOptions ?? [],
|
||||
);
|
||||
const [newOption, setNewOption] = useState('');
|
||||
const [isRequired, setIsRequired] = useState(field?.isRequired ?? false);
|
||||
const [sortOrder, setSortOrder] = useState(field?.sortOrder ?? 0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset state when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setEntityType(field?.entityType ?? 'client');
|
||||
setFieldName(field?.fieldName ?? '');
|
||||
setFieldLabel(field?.fieldLabel ?? '');
|
||||
setFieldType(field?.fieldType ?? 'text');
|
||||
setSelectOptions(field?.selectOptions ?? []);
|
||||
setNewOption('');
|
||||
setIsRequired(field?.isRequired ?? false);
|
||||
setSortOrder(field?.sortOrder ?? 0);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, field]);
|
||||
|
||||
// ── Select options management ──────────────────────────────────────────────
|
||||
|
||||
function addOption() {
|
||||
const trimmed = newOption.trim();
|
||||
if (!trimmed || selectOptions.includes(trimmed)) return;
|
||||
setSelectOptions((prev) => [...prev, trimmed]);
|
||||
setNewOption('');
|
||||
}
|
||||
|
||||
function removeOption(opt: string) {
|
||||
setSelectOptions((prev) => prev.filter((o) => o !== opt));
|
||||
}
|
||||
|
||||
function handleOptionKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addOption();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (fieldType === 'select' && selectOptions.length === 0) {
|
||||
setError('Select fields must have at least one option.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/admin/custom-fields/${field.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
fieldLabel,
|
||||
selectOptions: fieldType === 'select' ? selectOptions : undefined,
|
||||
isRequired,
|
||||
sortOrder,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/custom-fields', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
entityType,
|
||||
fieldName,
|
||||
fieldLabel,
|
||||
fieldType,
|
||||
selectOptions: fieldType === 'select' ? selectOptions : undefined,
|
||||
isRequired,
|
||||
sortOrder,
|
||||
},
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? 'Edit Custom Field' : 'New Custom Field'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5 py-2">
|
||||
{/* Entity Type — create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-entity-type">Entity Type</Label>
|
||||
{isEdit ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{ENTITY_TYPE_LABELS[entityType] ?? entityType}
|
||||
</p>
|
||||
) : (
|
||||
<Select value={entityType} onValueChange={setEntityType}>
|
||||
<SelectTrigger id="cf-entity-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(ENTITY_TYPE_LABELS).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field Name — create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-name">
|
||||
Field Name
|
||||
<span className="ml-1 text-xs text-muted-foreground">(snake_case)</span>
|
||||
</Label>
|
||||
{isEdit ? (
|
||||
<p className="font-mono text-sm text-muted-foreground">{fieldName}</p>
|
||||
) : (
|
||||
<Input
|
||||
id="cf-field-name"
|
||||
value={fieldName}
|
||||
onChange={(e) => setFieldName(e.target.value)}
|
||||
placeholder="e.g. vessel_type"
|
||||
pattern="^[a-z_][a-z0-9_]*$"
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Field Label */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-label">Display Label</Label>
|
||||
<Input
|
||||
id="cf-field-label"
|
||||
value={fieldLabel}
|
||||
onChange={(e) => setFieldLabel(e.target.value)}
|
||||
placeholder="e.g. Vessel Type"
|
||||
maxLength={100}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Field Type — create only */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-field-type">Field Type</Label>
|
||||
{isEdit ? (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{FIELD_TYPE_LABELS[fieldType] ?? fieldType}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Type cannot be changed after creation.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={fieldType} onValueChange={setFieldType}>
|
||||
<SelectTrigger id="cf-field-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(FIELD_TYPE_LABELS).map(([val, label]) => (
|
||||
<SelectItem key={val} value={val}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Select Options — visible when fieldType = 'select' */}
|
||||
{fieldType === 'select' && (
|
||||
<div className="space-y-2">
|
||||
<Label>Options</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newOption}
|
||||
onChange={(e) => setNewOption(e.target.value)}
|
||||
onKeyDown={handleOptionKeyDown}
|
||||
placeholder="Add option..."
|
||||
maxLength={100}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addOption}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{selectOptions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{selectOptions.map((opt) => (
|
||||
<span
|
||||
key={opt}
|
||||
className="flex items-center gap-1 rounded-md border bg-muted px-2 py-0.5 text-sm"
|
||||
>
|
||||
{opt}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(opt)}
|
||||
className="ml-0.5 text-muted-foreground hover:text-destructive"
|
||||
aria-label={`Remove option ${opt}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Is Required */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="cf-is-required">Required field</Label>
|
||||
<Switch
|
||||
id="cf-is-required"
|
||||
checked={isRequired}
|
||||
onCheckedChange={setIsRequired}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort Order */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="cf-sort-order">Sort Order</Label>
|
||||
<Input
|
||||
id="cf-sort-order"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(parseInt(e.target.value, 10) || 0)}
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Field'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
224
src/components/admin/custom-fields/custom-fields-manager.tsx
Normal file
224
src/components/admin/custom-fields/custom-fields-manager.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, Plus } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import {
|
||||
CustomFieldForm,
|
||||
type CustomFieldDefinition,
|
||||
} from './custom-field-form';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type EntityTab = 'client' | 'interest' | 'berth';
|
||||
|
||||
const TAB_LABELS: Record<EntityTab, string> = {
|
||||
client: 'Clients',
|
||||
interest: 'Interests',
|
||||
berth: 'Berths',
|
||||
};
|
||||
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
date: 'Date',
|
||||
boolean: 'Yes / No',
|
||||
select: 'Dropdown',
|
||||
};
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CustomFieldsManager() {
|
||||
const [activeTab, setActiveTab] = useState<EntityTab>('client');
|
||||
const [fields, setFields] = useState<CustomFieldDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingField, setEditingField] = useState<CustomFieldDefinition | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchFields = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: CustomFieldDefinition[] }>(
|
||||
'/api/v1/admin/custom-fields',
|
||||
);
|
||||
setFields(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchFields();
|
||||
}, [fetchFields]);
|
||||
|
||||
const filteredFields = fields.filter((f) => f.entityType === activeTab);
|
||||
|
||||
function handleCreate() {
|
||||
setEditingField(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEdit(field: CustomFieldDefinition) {
|
||||
setEditingField(field);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/custom-fields/${id}`, { method: 'DELETE' });
|
||||
await fetchFields();
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function getDeleteDescription(field: CustomFieldDefinition): string {
|
||||
return `Are you sure you want to delete "${field.fieldLabel}" (${field.fieldName})? All stored values for this field will also be permanently deleted.`;
|
||||
}
|
||||
|
||||
const columns: ColumnDef<CustomFieldDefinition, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'fieldName',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-sm">{row.original.fieldName}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'fieldLabel',
|
||||
header: 'Label',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.fieldLabel}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'fieldType',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{FIELD_TYPE_LABELS[row.original.fieldType] ?? row.original.fieldType}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isRequired',
|
||||
header: 'Required',
|
||||
cell: ({ row }) =>
|
||||
row.original.isRequired ? (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Required
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Optional</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'sortOrder',
|
||||
header: 'Order',
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{row.original.sortOrder}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={deletingId === row.original.id}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
}
|
||||
title="Delete Custom Field"
|
||||
description={getDeleteDescription(row.original)}
|
||||
confirmLabel="Delete Field"
|
||||
onConfirm={() => handleDelete(row.original.id)}
|
||||
loading={deletingId === row.original.id}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 80,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Custom Fields"
|
||||
description="Define custom fields for clients and records"
|
||||
actions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Field
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as EntityTab)}>
|
||||
<TabsList>
|
||||
{(Object.keys(TAB_LABELS) as EntityTab[]).map((tab) => (
|
||||
<TabsTrigger key={tab} value={tab}>
|
||||
{TAB_LABELS[tab]}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{(Object.keys(TAB_LABELS) as EntityTab[]).map((tab) => (
|
||||
<TabsContent key={tab} value={tab} className="mt-4">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredFields}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No custom fields for {TAB_LABELS[tab]} yet.
|
||||
</p>
|
||||
<Button variant="link" onClick={handleCreate} className="mt-2">
|
||||
Create the first field
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
|
||||
<CustomFieldForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
field={editingField}
|
||||
onSuccess={fetchFields}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
src/components/admin/document-templates/template-form.tsx
Normal file
239
src/components/admin/document-templates/template-form.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{ value: 'eoi', label: 'Expression of Interest' },
|
||||
{ value: 'contract', label: 'Contract' },
|
||||
{ value: 'nda', label: 'NDA' },
|
||||
{ value: 'reservation_agreement', label: 'Reservation Agreement' },
|
||||
{ value: 'letter', label: 'Letter' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
] as const;
|
||||
|
||||
const EMPTY_DOC = JSON.stringify(
|
||||
{
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: '' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
interface AdminTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
templateType: string;
|
||||
bodyHtml: string;
|
||||
version: number;
|
||||
isActive: boolean;
|
||||
content: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface TemplateFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
template?: AdminTemplate | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function TemplateForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
template,
|
||||
onSuccess,
|
||||
}: TemplateFormProps) {
|
||||
const isEdit = !!template;
|
||||
|
||||
const [name, setName] = useState(template?.name ?? '');
|
||||
const [type, setType] = useState(template?.templateType ?? 'other');
|
||||
const [contentJson, setContentJson] = useState(
|
||||
template?.content
|
||||
? JSON.stringify(template.content, null, 2)
|
||||
: EMPTY_DOC,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
||||
function validateJson(value: string): boolean {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setJsonError(null);
|
||||
return true;
|
||||
} catch {
|
||||
setJsonError('Invalid JSON — check syntax.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!validateJson(contentJson)) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const content = JSON.parse(contentJson) as Record<string, unknown>;
|
||||
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/admin/templates/${template.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name, content },
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/admin/templates', {
|
||||
method: 'POST',
|
||||
body: { name, type, content },
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full max-w-2xl overflow-y-auto sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{isEdit ? 'Edit Template' : 'New Document Template'}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-name">Template Name</Label>
|
||||
<Input
|
||||
id="template-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. EOI Confirmation Letter"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type — only on create */}
|
||||
{!isEdit && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-type">Document Type</Label>
|
||||
<Select value={type} onValueChange={setType}>
|
||||
<SelectTrigger id="template-type">
|
||||
<SelectValue placeholder="Select type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DOCUMENT_TYPES.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TipTap JSON Content */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="template-content">
|
||||
Document Content (TipTap JSON)
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste or edit TipTap JSON. Use{' '}
|
||||
<code className="rounded bg-muted px-1 text-xs">
|
||||
{'{{variable.key}}'}
|
||||
</code>{' '}
|
||||
tokens for dynamic content.
|
||||
</p>
|
||||
<textarea
|
||||
id="template-content"
|
||||
value={contentJson}
|
||||
onChange={(e) => {
|
||||
setContentJson(e.target.value);
|
||||
validateJson(e.target.value);
|
||||
}}
|
||||
rows={18}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs shadow-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-destructive">{jsonError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Variables Reference */}
|
||||
<details className="rounded-md border p-3">
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Available Template Variables
|
||||
</summary>
|
||||
<div className="mt-3 grid grid-cols-1 gap-1 sm:grid-cols-2">
|
||||
{TEMPLATE_VARIABLES.map((v) => (
|
||||
<div key={v.key} className="text-xs">
|
||||
<code className="rounded bg-muted px-1">
|
||||
{`{{${v.key}}}`}
|
||||
</code>{' '}
|
||||
<span className="text-muted-foreground">— {v.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{error && (
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !!jsonError}>
|
||||
{loading
|
||||
? 'Saving…'
|
||||
: isEdit
|
||||
? 'Save Changes'
|
||||
: 'Create Template'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
256
src/components/admin/document-templates/template-list.tsx
Normal file
256
src/components/admin/document-templates/template-list.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Plus, Pencil, Trash2, History, FileText } from 'lucide-react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TemplateForm } from './template-form';
|
||||
import { TemplateVersionHistory } from './template-version-history';
|
||||
import { TemplatePreview } from './template-preview';
|
||||
|
||||
interface AdminTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
templateType: string;
|
||||
bodyHtml: string;
|
||||
version: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
content: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
eoi: 'EOI',
|
||||
contract: 'Contract',
|
||||
nda: 'NDA',
|
||||
reservation_agreement: 'Reservation Agreement',
|
||||
letter: 'Letter',
|
||||
other: 'Other',
|
||||
welcome_letter: 'Welcome Letter',
|
||||
handover_checklist: 'Handover Checklist',
|
||||
acknowledgment: 'Acknowledgment',
|
||||
correspondence: 'Correspondence',
|
||||
custom: 'Custom',
|
||||
};
|
||||
|
||||
export function TemplateList() {
|
||||
const [templates, setTemplates] = useState<AdminTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTemplate, setEditingTemplate] = useState<AdminTemplate | null>(null);
|
||||
const [historyTemplate, setHistoryTemplate] = useState<AdminTemplate | null>(null);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: AdminTemplate[] }>(
|
||||
'/api/v1/admin/templates',
|
||||
);
|
||||
setTemplates(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTemplates();
|
||||
}, [fetchTemplates]);
|
||||
|
||||
function handleNewTemplate() {
|
||||
setEditingTemplate(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditTemplate(template: AdminTemplate) {
|
||||
setEditingTemplate(template);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleViewHistory(template: AdminTemplate) {
|
||||
setHistoryTemplate(template);
|
||||
setHistoryOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteTemplate(id: string) {
|
||||
await apiFetch(`/api/v1/admin/templates/${id}`, { method: 'DELETE' });
|
||||
await fetchTemplates();
|
||||
}
|
||||
|
||||
async function handleToggleActive(template: AdminTemplate) {
|
||||
await apiFetch(`/api/v1/admin/templates/${template.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { isActive: !template.isActive },
|
||||
});
|
||||
await fetchTemplates();
|
||||
}
|
||||
|
||||
const columns: ColumnDef<AdminTemplate, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'templateType',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary">
|
||||
{TYPE_LABELS[row.original.templateType] ?? row.original.templateType}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'version',
|
||||
header: 'Version',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
v{row.original.version}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'isActive',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={row.original.isActive ? 'default' : 'outline'}>
|
||||
{row.original.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'updatedAt',
|
||||
header: 'Last Updated',
|
||||
cell: ({ row }) =>
|
||||
new Date(row.original.updatedAt).toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TemplatePreview
|
||||
content={row.original.content}
|
||||
templateName={row.original.name}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Version history"
|
||||
onClick={() => handleViewHistory(row.original)}
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Edit template"
|
||||
onClick={() => handleEditTemplate(row.original)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={row.original.isActive ? 'Deactivate' : 'Activate'}
|
||||
onClick={() => handleToggleActive(row.original)}
|
||||
>
|
||||
<span className="text-xs">
|
||||
{row.original.isActive ? 'Off' : 'On'}
|
||||
</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" title="Delete template">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
}
|
||||
title="Delete Template"
|
||||
description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
destructive
|
||||
onConfirm={() => handleDeleteTemplate(row.original.id)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Document Templates"
|
||||
description="Manage reusable document templates with TipTap content and PDF generation"
|
||||
actions={
|
||||
<Button onClick={handleNewTemplate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Template
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={templates}
|
||||
isLoading={loading}
|
||||
emptyState={
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
No document templates yet. Create your first template to get started.
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<TemplateForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
template={editingTemplate}
|
||||
onSuccess={fetchTemplates}
|
||||
/>
|
||||
|
||||
{/* Version History Sheet */}
|
||||
<Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<SheetContent className="w-full max-w-xl sm:max-w-xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
Version History — {historyTemplate?.name}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-6">
|
||||
{historyTemplate && (
|
||||
<TemplateVersionHistory
|
||||
templateId={historyTemplate.id}
|
||||
currentVersion={historyTemplate.version}
|
||||
onRollback={() => {
|
||||
void fetchTemplates();
|
||||
setHistoryOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
133
src/components/admin/document-templates/template-preview.tsx
Normal file
133
src/components/admin/document-templates/template-preview.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Eye, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TEMPLATE_VARIABLES } from '@/lib/pdf/tiptap-to-pdfme';
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
content: Record<string, unknown> | null;
|
||||
templateName: string;
|
||||
}
|
||||
|
||||
export function TemplatePreview({ content, templateName }: TemplatePreviewProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pdfBase64, setPdfBase64] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Build sample data from TEMPLATE_VARIABLES examples
|
||||
const sampleData = Object.fromEntries(
|
||||
TEMPLATE_VARIABLES.map((v) => [v.key, v.example]),
|
||||
);
|
||||
|
||||
async function handlePreview() {
|
||||
if (!content) {
|
||||
setError('No content to preview.');
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setPdfBase64(null);
|
||||
setOpen(true);
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ data: { pdfBase64: string } }>(
|
||||
'/api/v1/admin/templates/preview',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { content, sampleData },
|
||||
},
|
||||
);
|
||||
setPdfBase64(res.data.pdfBase64);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Preview generation failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenInNewTab() {
|
||||
if (!pdfBase64) return;
|
||||
const blob = base64ToBlob(pdfBase64, 'application/pdf');
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handlePreview}>
|
||||
<Eye className="mr-1.5 h-3.5 w-3.5" />
|
||||
Preview PDF
|
||||
</Button>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Preview — {templateName}</DialogTitle>
|
||||
{pdfBase64 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleOpenInNewTab}
|
||||
className="mr-6"
|
||||
>
|
||||
<ExternalLink className="mr-1.5 h-3.5 w-3.5" />
|
||||
Open in new tab
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-2 min-h-[60vh]">
|
||||
{loading && (
|
||||
<div className="flex h-60 items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent mx-auto" />
|
||||
<p className="text-sm">Generating preview…</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="rounded bg-destructive/10 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pdfBase64 && !loading && (
|
||||
<iframe
|
||||
src={`data:application/pdf;base64,${pdfBase64}`}
|
||||
className="h-[70vh] w-full rounded border"
|
||||
title={`Preview: ${templateName}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preview uses sample values for all template variables.
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function base64ToBlob(base64: string, mimeType: string): Blob {
|
||||
const byteCharacters = atob(base64);
|
||||
const byteNumbers = new Uint8Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
return new Blob([byteNumbers], { type: mimeType });
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { RotateCcw, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TemplateVersion {
|
||||
version: number;
|
||||
content: Record<string, unknown>;
|
||||
changedBy: string | null;
|
||||
changedAt: string;
|
||||
auditLogId: string;
|
||||
}
|
||||
|
||||
interface TemplateVersionHistoryProps {
|
||||
templateId: string;
|
||||
currentVersion: number;
|
||||
onRollback: () => void;
|
||||
}
|
||||
|
||||
export function TemplateVersionHistory({
|
||||
templateId,
|
||||
currentVersion,
|
||||
onRollback,
|
||||
}: TemplateVersionHistoryProps) {
|
||||
const [versions, setVersions] = useState<TemplateVersion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rollingBack, setRollingBack] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<{ data: TemplateVersion[] }>(
|
||||
`/api/v1/admin/templates/${templateId}/versions`,
|
||||
);
|
||||
setVersions(res.data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load versions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchVersions();
|
||||
}, [fetchVersions]);
|
||||
|
||||
async function handleRollback(version: number) {
|
||||
if (!confirm(`Roll back to version ${version}? This will create a new version ${currentVersion + 1}.`)) return;
|
||||
|
||||
setRollingBack(version);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/admin/templates/${templateId}/rollback`, {
|
||||
method: 'POST',
|
||||
body: { version },
|
||||
});
|
||||
onRollback();
|
||||
await fetchVersions();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Rollback failed');
|
||||
} finally {
|
||||
setRollingBack(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
Loading version history…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (versions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 rounded-md border border-dashed p-6 text-center">
|
||||
<Clock className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No previous versions found. Versions are saved whenever you update the
|
||||
template content.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<p className="rounded bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Current version: <strong>v{currentVersion}</strong>. Click Restore to
|
||||
roll back to a previous version (creates a new version).
|
||||
</p>
|
||||
|
||||
<div className="divide-y rounded-md border">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.auditLogId}
|
||||
className="flex items-center justify-between px-4 py-3"
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">v{v.version}</Badge>
|
||||
<span className="text-sm font-medium">
|
||||
Version {v.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saved{' '}
|
||||
{new Date(v.changedAt).toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
{v.changedBy ? ` by ${v.changedBy}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRollback(v.version)}
|
||||
disabled={rollingBack === v.version}
|
||||
>
|
||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||
{rollingBack === v.version ? 'Restoring…' : 'Restore'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
src/components/admin/queue-detail-table.tsx
Normal file
211
src/components/admin/queue-detail-table.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Trash2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { QueueJobSummary, PaginatedQueueJobs } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
type JobStatus = 'waiting' | 'active' | 'completed' | 'failed' | 'delayed';
|
||||
|
||||
interface QueueDetailTableProps {
|
||||
queueName: string;
|
||||
}
|
||||
|
||||
const statusVariant: Record<JobStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
waiting: 'outline',
|
||||
active: 'default',
|
||||
completed: 'secondary',
|
||||
failed: 'destructive',
|
||||
delayed: 'outline',
|
||||
};
|
||||
|
||||
function formatDate(ts: number | undefined): string {
|
||||
if (!ts) return '—';
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
function truncateId(id: string): string {
|
||||
return id.length > 12 ? `${id.slice(0, 8)}...` : id;
|
||||
}
|
||||
|
||||
function truncateReason(reason: string | undefined): string {
|
||||
if (!reason) return '—';
|
||||
return reason.length > 80 ? `${reason.slice(0, 80)}…` : reason;
|
||||
}
|
||||
|
||||
export function QueueDetailTable({ queueName }: QueueDetailTableProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [status, setStatus] = useState<JobStatus>('failed');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['queue', 'jobs', queueName, status, page],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: PaginatedQueueJobs }>(
|
||||
`/api/v1/admin/queues/${queueName}?status=${status}&page=${page}&limit=${limit}`,
|
||||
).then((r) => r.data),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const retryMutation = useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}/retry`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (jobId: string) =>
|
||||
apiFetch(`/api/v1/admin/queues/${queueName}/${jobId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['queue', 'jobs', queueName] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['system', 'queues'] });
|
||||
},
|
||||
});
|
||||
|
||||
const jobs: QueueJobSummary[] = data?.jobs ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
function handleStatusChange(value: string) {
|
||||
setStatus(value as JobStatus);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs value={status} onValueChange={handleStatusChange}>
|
||||
<TabsList>
|
||||
{(['waiting', 'active', 'completed', 'failed', 'delayed'] as JobStatus[]).map((s) => (
|
||||
<TabsTrigger key={s} value={s} className="capitalize">
|
||||
{s}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Processed</TableHead>
|
||||
<TableHead className="max-w-[240px]">Failed Reason</TableHead>
|
||||
<TableHead className="w-[100px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
Loading jobs...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
|
||||
No {status} jobs
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-mono text-xs" title={job.id}>
|
||||
{truncateId(job.id)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{job.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant[status]}>{status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(job.timestamp)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{formatDate(job.processedOn)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="text-xs text-muted-foreground max-w-[240px] truncate"
|
||||
title={job.failedReason}
|
||||
>
|
||||
{truncateReason(job.failedReason)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
{status === 'failed' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
title="Retry job"
|
||||
disabled={retryMutation.isPending}
|
||||
onClick={() => retryMutation.mutate(job.id)}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:text-destructive"
|
||||
title="Delete job"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(job.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span>
|
||||
{total} total jobs — page {page} of {totalPages}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
src/components/admin/queue-overview.tsx
Normal file
75
src/components/admin/queue-overview.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { QueueStatus } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
interface QueueOverviewProps {
|
||||
queues: QueueStatus[];
|
||||
}
|
||||
|
||||
interface StatPillProps {
|
||||
label: string;
|
||||
value: number;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
function StatPill({ label, value, highlight }: StatPillProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'text-lg font-bold leading-none',
|
||||
highlight && value > 0 ? 'text-destructive' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground mt-0.5 uppercase tracking-wide">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueueOverview({ queues }: QueueOverviewProps) {
|
||||
const router = useRouter();
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
|
||||
function handleQueueClick(queueName: string) {
|
||||
router.push(`/${params.portSlug}/admin/monitoring/${queueName}` as any);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{queues.map((queue) => (
|
||||
<Card
|
||||
key={queue.name}
|
||||
className="cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onClick={() => handleQueueClick(queue.name)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleQueueClick(queue.name)}
|
||||
>
|
||||
<CardHeader className="p-3 pb-0">
|
||||
<CardTitle className="text-xs font-semibold capitalize truncate">{queue.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-2">
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1">
|
||||
<StatPill label="wait" value={queue.waiting} />
|
||||
<StatPill label="active" value={queue.active} />
|
||||
<StatPill label="done" value={queue.completed} />
|
||||
<StatPill label="failed" value={queue.failed} highlight />
|
||||
</div>
|
||||
{queue.delayed > 0 && (
|
||||
<p className="text-[10px] text-muted-foreground text-center mt-1">
|
||||
{queue.delayed} delayed
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
src/components/admin/service-health-card.tsx
Normal file
52
src/components/admin/service-health-card.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import type { ServiceStatus } from '@/lib/services/system-monitoring.service';
|
||||
|
||||
interface ServiceHealthCardProps {
|
||||
service: ServiceStatus;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
healthy: {
|
||||
dot: 'bg-green-500',
|
||||
label: 'Healthy',
|
||||
labelClass: 'text-green-700 dark:text-green-400',
|
||||
},
|
||||
degraded: {
|
||||
dot: 'bg-yellow-500',
|
||||
label: 'Degraded',
|
||||
labelClass: 'text-yellow-700 dark:text-yellow-400',
|
||||
},
|
||||
down: {
|
||||
dot: 'bg-red-500',
|
||||
label: 'Down',
|
||||
labelClass: 'text-red-700 dark:text-red-400',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function ServiceHealthCard({ service }: ServiceHealthCardProps) {
|
||||
const config = statusConfig[service.status];
|
||||
|
||||
return (
|
||||
<Card className="min-w-[160px]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
className={cn('h-2.5 w-2.5 rounded-full flex-shrink-0', config.dot)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="font-medium text-sm truncate">{service.name}</span>
|
||||
</div>
|
||||
<p className={cn('text-xs font-semibold', config.labelClass)}>{config.label}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{service.responseTimeMs}ms</p>
|
||||
{service.details && (
|
||||
<p className="text-xs text-muted-foreground mt-1 truncate" title={service.details}>
|
||||
{service.details}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
197
src/components/admin/system-monitoring-dashboard.tsx
Normal file
197
src/components/admin/system-monitoring-dashboard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Activity, Wifi, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ServiceHealthCard } from './service-health-card';
|
||||
import { QueueOverview } from './queue-overview';
|
||||
import type {
|
||||
HealthStatus,
|
||||
QueueStatus,
|
||||
ConnectionStatus,
|
||||
RecentError,
|
||||
} from '@/lib/services/system-monitoring.service';
|
||||
|
||||
export function SystemMonitoringDashboard() {
|
||||
const { data: healthData } = useQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: HealthStatus }>('/api/v1/admin/health').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: queuesData } = useQuery({
|
||||
queryKey: ['system', 'queues'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: QueueStatus[] }>('/api/v1/admin/queues').then((r) => r.data),
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
const { data: connectionsData } = useQuery({
|
||||
queryKey: ['system', 'connections'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: ConnectionStatus }>('/api/v1/admin/connections').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const queues: QueueStatus[] = queuesData ?? [];
|
||||
const health: HealthStatus | undefined = healthData;
|
||||
const connections = connectionsData?.totalConnections ?? 0;
|
||||
|
||||
const totalFailed = queues.reduce((sum, q) => sum + q.failed, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Page header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">System Monitoring</h1>
|
||||
<p className="text-muted-foreground">Real-time health, queue status and connection tracking</p>
|
||||
</div>
|
||||
|
||||
{/* Service health */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Service Health
|
||||
</h2>
|
||||
{health ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{health.overall === 'healthy' ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500" />
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
All services checked at {new Date(health.checkedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{health.services.map((service) => (
|
||||
<ServiceHealthCard key={service.name} service={service} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[88px] w-[160px] rounded-xl border bg-card animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Stats row */}
|
||||
<section className="grid grid-cols-2 sm:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
|
||||
<Wifi className="h-3.5 w-3.5" />
|
||||
Active Connections
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">{connections}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Total Failed Jobs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className={`text-3xl font-bold ${totalFailed > 0 ? 'text-destructive' : ''}`}>
|
||||
{totalFailed}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-xs text-muted-foreground font-medium uppercase tracking-wide flex items-center gap-1.5">
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
Active Jobs
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-3xl font-bold">
|
||||
{queues.reduce((sum, q) => sum + q.active, 0)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Queue overview */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Queue Overview
|
||||
</h2>
|
||||
{queues.length > 0 ? (
|
||||
<QueueOverview queues={queues} />
|
||||
) : (
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[110px] rounded-xl border bg-card animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Recent errors */}
|
||||
<RecentErrorsPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentErrorsPanel() {
|
||||
const { data: errorsData } = useQuery({
|
||||
queryKey: ['system', 'errors'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ data: RecentError[] }>('/api/v1/admin/errors').then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const errors: RecentError[] = errorsData ?? [];
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Recent Errors
|
||||
</h2>
|
||||
{errors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent errors.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="flex items-start justify-between gap-4 rounded-lg border px-4 py-3 text-sm"
|
||||
>
|
||||
<div className="space-y-0.5 min-w-0">
|
||||
<p className="font-medium truncate">{error.message}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{error.source === 'queue' ? 'Queue' : 'Audit'} —{' '}
|
||||
{new Date(error.timestamp).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
142
src/components/admin/tags/tag-form.tsx
Normal file
142
src/components/admin/tags/tag-form.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface TagFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tag?: { id: string; name: string; color: string } | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#6B7280', '#EF4444', '#F97316', '#EAB308',
|
||||
'#22C55E', '#14B8A6', '#3B82F6', '#8B5CF6',
|
||||
'#EC4899', '#F43F5E',
|
||||
];
|
||||
|
||||
export function TagForm({ open, onOpenChange, tag, onSuccess }: TagFormProps) {
|
||||
const [name, setName] = useState(tag?.name ?? '');
|
||||
const [color, setColor] = useState(tag?.color ?? '#6B7280');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isEdit = !!tag;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/tags/${tag.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name, color },
|
||||
});
|
||||
} else {
|
||||
await apiFetch('/api/v1/tags', {
|
||||
method: 'POST',
|
||||
body: { name, color },
|
||||
});
|
||||
}
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
setName(tag?.name ?? '');
|
||||
setColor(tag?.color ?? '#6B7280');
|
||||
setError(null);
|
||||
}
|
||||
onOpenChange(open);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={handleOpenChange}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Tag' : 'New Tag'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tag-name">Name</Label>
|
||||
<Input
|
||||
id="tag-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. VIP Client"
|
||||
maxLength={50}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Color</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className="h-7 w-7 rounded-full ring-2 ring-offset-2 transition-all"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
outline: color === c ? `2px solid ${c}` : '2px solid transparent',
|
||||
outlineOffset: '2px',
|
||||
}}
|
||||
onClick={() => setColor(c)}
|
||||
aria-label={`Select color ${c}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div
|
||||
className="h-7 w-7 rounded-full border"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<Input
|
||||
value={color}
|
||||
onChange={(e) => setColor(e.target.value)}
|
||||
placeholder="#6B7280"
|
||||
className="w-28 font-mono text-sm"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !name.trim()}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Tag'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
169
src/components/admin/tags/tag-list.tsx
Normal file
169
src/components/admin/tags/tag-list.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { Pencil, Trash2, Plus } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ConfirmationDialog } from '@/components/shared/confirmation-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { TagForm } from './tag-form';
|
||||
|
||||
interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function TagList() {
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const fetchTags = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<{ data: Tag[] }>('/api/v1/tags');
|
||||
setTags(res.data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchTags();
|
||||
}, [fetchTags]);
|
||||
|
||||
function handleNewTag() {
|
||||
setEditingTag(null);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditTag(tag: Tag) {
|
||||
setEditingTag(tag);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteTag(id: string) {
|
||||
setDeletingId(id);
|
||||
try {
|
||||
await apiFetch(`/api/v1/tags/${id}`, { method: 'DELETE' });
|
||||
await fetchTags();
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnDef<Tag, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="inline-block h-3 w-3 rounded-full"
|
||||
style={{ backgroundColor: row.original.color }}
|
||||
/>
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'color',
|
||||
header: 'Color',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono text-xs"
|
||||
style={{ borderColor: row.original.color, color: row.original.color }}
|
||||
>
|
||||
{row.original.color}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created',
|
||||
cell: ({ row }) =>
|
||||
new Date(row.original.createdAt).toLocaleDateString(),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditTag(row.original)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit</span>
|
||||
</Button>
|
||||
<ConfirmationDialog
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
}
|
||||
title="Delete Tag"
|
||||
description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
onConfirm={() => handleDeleteTag(row.original.id)}
|
||||
loading={deletingId === row.original.id}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
size: 80,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Tag Management"
|
||||
description="Manage tags used across clients and records"
|
||||
actions={
|
||||
<Button onClick={handleNewTag}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Tag
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={tags}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No tags yet.</p>
|
||||
<Button variant="link" onClick={handleNewTag} className="mt-2">
|
||||
Create your first tag
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<TagForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
tag={editingTag}
|
||||
onSuccess={fetchTags}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
src/components/admin/webhooks/webhook-delivery-log.tsx
Normal file
135
src/components/admin/webhooks/webhook-delivery-log.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface Delivery {
|
||||
id: string;
|
||||
eventType: string;
|
||||
status: string;
|
||||
responseStatus: number | null;
|
||||
attempt: number;
|
||||
deliveredAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
webhookId: string;
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
success: 'default',
|
||||
pending: 'secondary',
|
||||
failed: 'destructive',
|
||||
dead_letter: 'destructive',
|
||||
};
|
||||
|
||||
export function WebhookDeliveryLog({ webhookId }: Props) {
|
||||
const [deliveries, setDeliveries] = useState<Delivery[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
async function load(p: number) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await apiFetch<{ data: Delivery[]; total: number }>(
|
||||
`/api/v1/admin/webhooks/${webhookId}/deliveries?page=${p}&limit=25`,
|
||||
);
|
||||
setDeliveries(result.data);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [webhookId, page]);
|
||||
|
||||
if (loading && deliveries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">Loading deliveries...</p>;
|
||||
}
|
||||
|
||||
if (!loading && deliveries.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No deliveries yet.</p>;
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / 25);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>HTTP</TableHead>
|
||||
<TableHead>Attempt</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deliveries.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-mono text-xs">{d.eventType}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_VARIANTS[d.status] ?? 'outline'}>
|
||||
{d.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{d.responseStatus ?? '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{d.attempt}</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{d.deliveredAt
|
||||
? new Date(d.deliveredAt).toLocaleString()
|
||||
: new Date(d.createdAt).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page} of {totalPages} ({total} total)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/admin/webhooks/webhook-event-selector.tsx
Normal file
110
src/components/admin/webhooks/webhook-event-selector.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { WEBHOOK_EVENTS, type WebhookEvent } from '@/lib/services/webhook-event-map';
|
||||
|
||||
// ─── Event Groups ─────────────────────────────────────────────────────────────
|
||||
|
||||
const EVENT_GROUPS: { label: string; events: WebhookEvent[] }[] = [
|
||||
{
|
||||
label: 'Clients',
|
||||
events: ['client.created', 'client.updated', 'client.archived', 'client.merged'],
|
||||
},
|
||||
{
|
||||
label: 'Interests',
|
||||
events: ['interest.created', 'interest.stage_changed', 'interest.berth_linked'],
|
||||
},
|
||||
{
|
||||
label: 'Berths',
|
||||
events: ['berth.status_changed', 'berth.updated'],
|
||||
},
|
||||
{
|
||||
label: 'Documents',
|
||||
events: ['document.sent', 'document.signed', 'document.completed', 'document.expired'],
|
||||
},
|
||||
{
|
||||
label: 'Expenses',
|
||||
events: ['expense.created', 'expense.updated'],
|
||||
},
|
||||
{
|
||||
label: 'Invoices',
|
||||
events: ['invoice.created', 'invoice.sent', 'invoice.paid', 'invoice.overdue'],
|
||||
},
|
||||
{
|
||||
label: 'Registrations',
|
||||
events: ['registration.new'],
|
||||
},
|
||||
];
|
||||
|
||||
interface WebhookEventSelectorProps {
|
||||
selected: WebhookEvent[];
|
||||
onChange: (events: WebhookEvent[]) => void;
|
||||
}
|
||||
|
||||
export function WebhookEventSelector({ selected, onChange }: WebhookEventSelectorProps) {
|
||||
function toggle(event: WebhookEvent) {
|
||||
if (selected.includes(event)) {
|
||||
onChange(selected.filter((e) => e !== event));
|
||||
} else {
|
||||
onChange([...selected, event]);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleGroup(events: WebhookEvent[]) {
|
||||
const allSelected = events.every((e) => selected.includes(e));
|
||||
if (allSelected) {
|
||||
onChange(selected.filter((e) => !events.includes(e)));
|
||||
} else {
|
||||
const newEvents = [...selected];
|
||||
for (const e of events) {
|
||||
if (!newEvents.includes(e)) newEvents.push(e);
|
||||
}
|
||||
onChange(newEvents);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{EVENT_GROUPS.map((group) => {
|
||||
const allChecked = group.events.every((e) => selected.includes(e));
|
||||
const someChecked = group.events.some((e) => selected.includes(e));
|
||||
return (
|
||||
<div key={group.label}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
id={`group-${group.label}`}
|
||||
checked={allChecked}
|
||||
data-state={someChecked && !allChecked ? 'indeterminate' : undefined}
|
||||
onCheckedChange={() => toggleGroup(group.events)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`group-${group.label}`}
|
||||
className="font-semibold text-sm cursor-pointer"
|
||||
>
|
||||
{group.label}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="ml-6 grid grid-cols-2 gap-1">
|
||||
{group.events.map((event) => (
|
||||
<div key={event} className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`event-${event}`}
|
||||
checked={selected.includes(event)}
|
||||
onCheckedChange={() => toggle(event)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`event-${event}`}
|
||||
className="text-xs font-mono cursor-pointer"
|
||||
>
|
||||
{event}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/components/admin/webhooks/webhook-form.tsx
Normal file
152
src/components/admin/webhooks/webhook-form.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { WebhookEventSelector } from './webhook-event-selector';
|
||||
import { WebhookSecretDisplay } from './webhook-secret-display';
|
||||
import type { WebhookEvent } from '@/lib/services/webhook-event-map';
|
||||
|
||||
interface WebhookFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
webhook?: {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
isActive: boolean;
|
||||
secretMasked: string;
|
||||
} | null;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function WebhookForm({ open, onOpenChange, webhook, onSuccess }: WebhookFormProps) {
|
||||
const isEdit = !!webhook;
|
||||
|
||||
const [name, setName] = useState(webhook?.name ?? '');
|
||||
const [url, setUrl] = useState(webhook?.url ?? '');
|
||||
const [events, setEvents] = useState<WebhookEvent[]>((webhook?.events ?? []) as WebhookEvent[]);
|
||||
const [isActive, setIsActive] = useState(webhook?.isActive ?? true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createdSecret, setCreatedSecret] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiFetch(`/api/v1/admin/webhooks/${webhook.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { name, url, events, isActive },
|
||||
});
|
||||
onSuccess();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
const result = await apiFetch<{ data: { secret: string } }>('/api/v1/admin/webhooks', {
|
||||
method: 'POST',
|
||||
body: { name, url, events, isActive },
|
||||
});
|
||||
setCreatedSecret(result.data.secret);
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Something went wrong';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
setName(webhook?.name ?? '');
|
||||
setUrl(webhook?.url ?? '');
|
||||
setEvents((webhook?.events ?? []) as WebhookEvent[]);
|
||||
setIsActive(webhook?.isActive ?? true);
|
||||
setError(null);
|
||||
setCreatedSecret(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(o) => { if (!o) handleClose(); }}>
|
||||
<SheetContent className="w-full sm:max-w-xl overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{isEdit ? 'Edit Webhook' : 'New Webhook'}</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
{createdSecret ? (
|
||||
<div className="mt-6 space-y-4">
|
||||
<p className="text-sm">Webhook created successfully.</p>
|
||||
<WebhookSecretDisplay plaintext={createdSecret} masked="" />
|
||||
<Button onClick={handleClose} className="w-full">Done</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="mt-6 space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-name">Name</Label>
|
||||
<Input
|
||||
id="webhook-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Zapier Integration"
|
||||
maxLength={200}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-url">URL</Label>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://hooks.example.com/webhook"
|
||||
type="url"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Events</Label>
|
||||
<WebhookEventSelector selected={events} onChange={setEvents} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
id="webhook-active"
|
||||
checked={isActive}
|
||||
onCheckedChange={setIsActive}
|
||||
/>
|
||||
<Label htmlFor="webhook-active">Active</Label>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<SheetFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !name.trim() || !url.trim() || events.length === 0}>
|
||||
{loading ? 'Saving...' : isEdit ? 'Save Changes' : 'Create Webhook'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
54
src/components/admin/webhooks/webhook-secret-display.tsx
Normal file
54
src/components/admin/webhooks/webhook-secret-display.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface WebhookSecretDisplayProps {
|
||||
/** Plaintext secret (shown once on creation). If undefined, shows masked. */
|
||||
plaintext?: string;
|
||||
/** Masked preview (always shown on view). */
|
||||
masked: string;
|
||||
}
|
||||
|
||||
export function WebhookSecretDisplay({ plaintext, masked }: WebhookSecretDisplayProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copySecret() {
|
||||
if (!plaintext) return;
|
||||
await navigator.clipboard.writeText(plaintext);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
if (plaintext) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="rounded-md bg-amber-50 border border-amber-200 p-3 text-sm text-amber-800">
|
||||
<strong>Copy this secret now.</strong> It will not be shown again.
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={plaintext}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={copySecret}>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={masked}
|
||||
className="font-mono text-sm text-muted-foreground"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Use "Regenerate" to get a new secret</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
162
src/components/berths/berth-columns.tsx
Normal file
162
src/components/berths/berth-columns.tsx
Normal 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} />,
|
||||
},
|
||||
];
|
||||
215
src/components/berths/berth-detail-header.tsx
Normal file
215
src/components/berths/berth-detail-header.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
37
src/components/berths/berth-detail.tsx
Normal file
37
src/components/berths/berth-detail.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/components/berths/berth-filters.tsx
Normal file
41
src/components/berths/berth-filters.tsx
Normal 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
|
||||
},
|
||||
];
|
||||
305
src/components/berths/berth-form.tsx
Normal file
305
src/components/berths/berth-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/berths/berth-list.tsx
Normal file
94
src/components/berths/berth-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/berths/berth-status-suggestion-dialog.tsx
Normal file
91
src/components/berths/berth-status-suggestion-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/berths/berth-tabs.tsx
Normal file
200
src/components/berths/berth-tabs.tsx
Normal 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" />,
|
||||
},
|
||||
];
|
||||
}
|
||||
269
src/components/berths/waiting-list-manager.tsx
Normal file
269
src/components/berths/waiting-list-manager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
src/components/clients/client-columns.tsx
Normal file
164
src/components/clients/client-columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
185
src/components/clients/client-detail-header.tsx
Normal file
185
src/components/clients/client-detail-header.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
82
src/components/clients/client-detail.tsx
Normal file
82
src/components/clients/client-detail.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
88
src/components/clients/client-files-tab.tsx
Normal file
88
src/components/clients/client-files-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/clients/client-filters.tsx
Normal file
37
src/components/clients/client-filters.tsx
Normal 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',
|
||||
},
|
||||
];
|
||||
436
src/components/clients/client-form.tsx
Normal file
436
src/components/clients/client-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/components/clients/client-list.tsx
Normal file
155
src/components/clients/client-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/components/clients/client-tabs.tsx
Normal file
208
src/components/clients/client-tabs.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
97
src/components/dashboard/activity-feed.tsx
Normal file
97
src/components/dashboard/activity-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/components/dashboard/dashboard-shell.tsx
Normal file
37
src/components/dashboard/dashboard-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/dashboard/kpi-cards.tsx
Normal file
98
src/components/dashboard/kpi-cards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/dashboard/pipeline-chart.tsx
Normal file
94
src/components/dashboard/pipeline-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/dashboard/revenue-forecast.tsx
Normal file
104
src/components/dashboard/revenue-forecast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/dashboard/widget-error-boundary.tsx
Normal file
54
src/components/dashboard/widget-error-boundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
149
src/components/documents/document-list.tsx
Normal file
149
src/components/documents/document-list.tsx
Normal 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">
|
||||
…
|
||||
</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>
|
||||
);
|
||||
}
|
||||
115
src/components/documents/eoi-generate-dialog.tsx
Normal file
115
src/components/documents/eoi-generate-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/documents/signing-progress.tsx
Normal file
93
src/components/documents/signing-progress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
src/components/email/email-draft-button.tsx
Normal file
200
src/components/email/email-draft-button.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
183
src/components/expenses/expense-columns.tsx
Normal file
183
src/components/expenses/expense-columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
203
src/components/expenses/expense-detail.tsx
Normal file
203
src/components/expenses/expense-detail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/components/expenses/expense-filters.tsx
Normal file
53
src/components/expenses/expense-filters.tsx
Normal 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',
|
||||
},
|
||||
];
|
||||
261
src/components/expenses/expense-form-dialog.tsx
Normal file
261
src/components/expenses/expense-form-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
src/components/files/file-grid.tsx
Normal file
144
src/components/files/file-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/files/file-preview-dialog.tsx
Normal file
112
src/components/files/file-preview-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
src/components/files/file-upload-zone.tsx
Normal file
185
src/components/files/file-upload-zone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/components/files/folder-tree.tsx
Normal file
139
src/components/files/folder-tree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
220
src/components/interests/interest-columns.tsx
Normal file
220
src/components/interests/interest-columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
211
src/components/interests/interest-detail-header.tsx
Normal file
211
src/components/interests/interest-detail-header.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
src/components/interests/interest-detail.tsx
Normal file
84
src/components/interests/interest-detail.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
68
src/components/interests/interest-documents-tab.tsx
Normal file
68
src/components/interests/interest-documents-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
src/components/interests/interest-files-tab.tsx
Normal file
93
src/components/interests/interest-files-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/interests/interest-filters.tsx
Normal file
72
src/components/interests/interest-filters.tsx
Normal 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',
|
||||
},
|
||||
];
|
||||
455
src/components/interests/interest-form.tsx
Normal file
455
src/components/interests/interest-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
src/components/interests/interest-list.tsx
Normal file
184
src/components/interests/interest-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/interests/interest-score-badge.tsx
Normal file
84
src/components/interests/interest-score-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/interests/interest-stage-picker.tsx
Normal file
130
src/components/interests/interest-stage-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
src/components/interests/interest-tabs.tsx
Normal file
156
src/components/interests/interest-tabs.tsx
Normal 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} />,
|
||||
},
|
||||
];
|
||||
}
|
||||
87
src/components/interests/interest-timeline.tsx
Normal file
87
src/components/interests/interest-timeline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/interests/pipeline-board.tsx
Normal file
131
src/components/interests/pipeline-board.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/interests/pipeline-card.tsx
Normal file
68
src/components/interests/pipeline-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/interests/pipeline-column.tsx
Normal file
63
src/components/interests/pipeline-column.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/interests/recommendation-list.tsx
Normal file
130
src/components/interests/recommendation-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/components/invoices/invoice-columns.tsx
Normal file
187
src/components/invoices/invoice-columns.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
367
src/components/invoices/invoice-detail.tsx
Normal file
367
src/components/invoices/invoice-detail.tsx
Normal 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 ?? '—'} · {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>
|
||||
);
|
||||
}
|
||||
45
src/components/invoices/invoice-filters.tsx
Normal file
45
src/components/invoices/invoice-filters.tsx
Normal 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',
|
||||
},
|
||||
];
|
||||
136
src/components/invoices/invoice-line-items.tsx
Normal file
136
src/components/invoices/invoice-line-items.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/components/invoices/invoice-pdf-preview.tsx
Normal file
96
src/components/invoices/invoice-pdf-preview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
src/components/layout/breadcrumbs.tsx
Normal file
129
src/components/layout/breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/layout/port-switcher.tsx
Normal file
56
src/components/layout/port-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
src/components/layout/sidebar.tsx
Normal file
345
src/components/layout/sidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
137
src/components/layout/topbar.tsx
Normal file
137
src/components/layout/topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
src/components/notifications/notification-bell.tsx
Normal file
110
src/components/notifications/notification-bell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/notifications/notification-item.tsx
Normal file
66
src/components/notifications/notification-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/portal/portal-card.tsx
Normal file
48
src/components/portal/portal-card.tsx
Normal 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;
|
||||
}
|
||||
59
src/components/portal/portal-header.tsx
Normal file
59
src/components/portal/portal-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/portal/portal-nav.tsx
Normal file
45
src/components/portal/portal-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/components/reports/generate-report-form.tsx
Normal file
142
src/components/reports/generate-report-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/reports/report-status-badge.tsx
Normal file
35
src/components/reports/report-status-badge.tsx
Normal 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>;
|
||||
}
|
||||
}
|
||||
164
src/components/reports/reports-list.tsx
Normal file
164
src/components/reports/reports-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/reports/reports-page-client.tsx
Normal file
27
src/components/reports/reports-page-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/components/search/command-search.tsx
Normal file
222
src/components/search/command-search.tsx
Normal 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 “{query}”
|
||||
</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;
|
||||
}
|
||||
78
src/components/search/search-result-item.tsx
Normal file
78
src/components/search/search-result-item.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/shared/archive-confirm-dialog.tsx
Normal file
66
src/components/shared/archive-confirm-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/shared/confirmation-dialog.tsx
Normal file
72
src/components/shared/confirmation-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
332
src/components/shared/custom-fields-section.tsx
Normal file
332
src/components/shared/custom-fields-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
274
src/components/shared/data-table.tsx
Normal file
274
src/components/shared/data-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/components/shared/detail-layout.tsx
Normal file
79
src/components/shared/detail-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/shared/empty-state.tsx
Normal file
44
src/components/shared/empty-state.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
266
src/components/shared/filter-bar.tsx
Normal file
266
src/components/shared/filter-bar.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
112
src/components/shared/loading-skeleton.tsx
Normal file
112
src/components/shared/loading-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/components/shared/notes-list.tsx
Normal file
194
src/components/shared/notes-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/shared/page-header.tsx
Normal file
27
src/components/shared/page-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/shared/permission-gate.tsx
Normal file
30
src/components/shared/permission-gate.tsx
Normal 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}</>;
|
||||
}
|
||||
135
src/components/shared/saved-views-dropdown.tsx
Normal file
135
src/components/shared/saved-views-dropdown.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
src/components/shared/tag-badge.tsx
Normal file
25
src/components/shared/tag-badge.tsx
Normal 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
Reference in New Issue
Block a user