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:
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>
|
||||
);
|
||||
}
|
||||
129
src/components/shared/tag-picker.tsx
Normal file
129
src/components/shared/tag-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user