Initial commit: Port Nimara CRM (Layers 0-4)
Some checks failed
Build & Push Docker Images / build-and-push (push) Has been cancelled
Build & Push Docker Images / deploy (push) Has been cancelled
Build & Push Docker Images / lint (push) Has been cancelled

Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:52:51 +01:00
commit 67d7e6e3d5
572 changed files with 86496 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
'use client';
import { useState } from 'react';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Badge } from '@/components/ui/badge';
import { useEntityOptions } from '@/hooks/use-entity-options';
interface TagPickerProps {
selectedIds: string[];
onChange: (ids: string[]) => void;
placeholder?: string;
}
export function TagPicker({
selectedIds,
onChange,
placeholder = 'Select tags...',
}: TagPickerProps) {
const [open, setOpen] = useState(false);
const { options, isLoading } = useEntityOptions({
endpoint: '/api/v1/tags/options',
labelKey: 'name',
valueKey: 'id',
});
// Extend options to include color
const tagOptions = options as Array<{ value: string; label: string; color?: string }>;
function toggleTag(tagId: string) {
if (selectedIds.includes(tagId)) {
onChange(selectedIds.filter((id) => id !== tagId));
} else {
onChange([...selectedIds, tagId]);
}
}
function removeTag(tagId: string) {
onChange(selectedIds.filter((id) => id !== tagId));
}
const selectedTags = tagOptions.filter((t) => selectedIds.includes(t.value));
return (
<div className="space-y-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
<span className="text-muted-foreground">
{selectedIds.length > 0
? `${selectedIds.length} tag${selectedIds.length > 1 ? 's' : ''} selected`
: placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<Command>
<CommandInput placeholder="Search tags..." />
<CommandList>
<CommandEmpty>
{isLoading ? 'Loading...' : 'No tags found.'}
</CommandEmpty>
<CommandGroup>
{tagOptions.map((tag) => (
<CommandItem
key={tag.value}
onSelect={() => toggleTag(tag.value)}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedIds.includes(tag.value) ? 'opacity-100' : 'opacity-0'
}`}
/>
<span
className="mr-2 h-3 w-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color ?? '#6B7280' }}
/>
{tag.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{/* Selected tags display */}
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{selectedTags.map((tag) => (
<Badge key={tag.value} variant="secondary" className="gap-1 pr-1">
<span
className="h-2 w-2 rounded-full shrink-0"
style={{ backgroundColor: tag.color ?? '#6B7280' }}
/>
{tag.label}
<button
className="ml-0.5 rounded-full hover:bg-muted-foreground/20 p-0.5"
onClick={() => removeTag(tag.value)}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
</div>
);
}