Add user settings, audit log, berth CRUD, and missing endpoints
- PATCH /api/v1/me: self-service profile update (name, phone, timezone) - User settings page with profile editor + notification preferences - Audit log API with filtering (entity, action, user, date range) - Audit log page with search, entity type, and action filters - Berth create/delete: POST /api/v1/berths + DELETE /api/v1/berths/[id] - Client duplicates endpoint: GET /api/v1/clients/duplicates?name= - Replace settings and audit stub pages with real implementations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
257
src/components/admin/audit/audit-log-list.tsx
Normal file
257
src/components/admin/audit/audit-log-list.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface AuditEntry {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
fieldChanged: string | null;
|
||||
oldValue: Record<string, unknown> | null;
|
||||
newValue: Record<string, unknown> | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
ipAddress: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: 'bg-green-600',
|
||||
update: 'bg-blue-500',
|
||||
delete: 'bg-red-600',
|
||||
archive: 'bg-orange-500',
|
||||
restore: 'bg-teal-500',
|
||||
login: 'bg-gray-500',
|
||||
permission_denied: 'bg-red-800',
|
||||
};
|
||||
|
||||
const ENTITY_TYPES = [
|
||||
'client',
|
||||
'interest',
|
||||
'berth',
|
||||
'document',
|
||||
'expense',
|
||||
'invoice',
|
||||
'reminder',
|
||||
'user',
|
||||
'role',
|
||||
'port',
|
||||
'setting',
|
||||
'tag',
|
||||
'webhook',
|
||||
];
|
||||
|
||||
export function AuditLogList() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [entityTypeFilter, setEntityTypeFilter] = useState<string>('all');
|
||||
const [actionFilter, setActionFilter] = useState<string>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: '50',
|
||||
});
|
||||
if (entityTypeFilter !== 'all') params.set('entityType', entityTypeFilter);
|
||||
if (actionFilter !== 'all') params.set('action', actionFilter);
|
||||
if (search) params.set('search', search);
|
||||
|
||||
const res = await apiFetch<{
|
||||
data: AuditEntry[];
|
||||
pagination: { total: number };
|
||||
}>(`/api/v1/admin/audit?${params}`);
|
||||
setEntries(res.data);
|
||||
setTotal(res.pagination.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, entityTypeFilter, actionFilter, search]);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
const columns: ColumnDef<AuditEntry, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Time',
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm">
|
||||
<div>{new Date(row.original.createdAt).toLocaleString()}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(row.original.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
size: 180,
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Action',
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
className={`${ACTION_COLORS[row.original.action] ?? 'bg-gray-500'} text-white text-xs`}
|
||||
>
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
size: 100,
|
||||
},
|
||||
{
|
||||
accessorKey: 'entityType',
|
||||
header: 'Entity',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<span className="font-medium capitalize">{row.original.entityType}</span>
|
||||
<code className="ml-2 text-xs text-muted-foreground">
|
||||
{row.original.entityId.slice(0, 8)}...
|
||||
</code>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'changes',
|
||||
header: 'Changes',
|
||||
cell: ({ row }) => {
|
||||
const { newValue, fieldChanged } = row.original;
|
||||
if (fieldChanged) return <span className="text-sm">{fieldChanged}</span>;
|
||||
if (newValue) {
|
||||
const keys = Object.keys(newValue);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keys.slice(0, 3).join(', ')}
|
||||
{keys.length > 3 ? ` +${keys.length - 3} more` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span className="text-xs text-muted-foreground">—</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'userId',
|
||||
header: 'User',
|
||||
cell: ({ row }) => (
|
||||
<code className="text-xs">
|
||||
{row.original.userId ? row.original.userId.slice(0, 8) + '...' : 'system'}
|
||||
</code>
|
||||
),
|
||||
size: 100,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Audit Log" description={`${total} entries`} />
|
||||
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={entityTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setEntityTypeFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Entities</SelectItem>
|
||||
{ENTITY_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={actionFilter}
|
||||
onValueChange={(v) => {
|
||||
setActionFilter(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Actions</SelectItem>
|
||||
<SelectItem value="create">Create</SelectItem>
|
||||
<SelectItem value="update">Update</SelectItem>
|
||||
<SelectItem value="delete">Delete</SelectItem>
|
||||
<SelectItem value="archive">Archive</SelectItem>
|
||||
<SelectItem value="restore">Restore</SelectItem>
|
||||
<SelectItem value="permission_denied">Permission Denied</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={entries}
|
||||
isLoading={loading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<div className="text-center py-8">
|
||||
<p className="text-muted-foreground">No audit log entries found.</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{total > 50 && (
|
||||
<div className="flex items-center justify-center gap-2 mt-4">
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Page {page} of {Math.ceil(total / 50)}
|
||||
</span>
|
||||
<button
|
||||
className="text-sm text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
disabled={page >= Math.ceil(total / 50)}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
174
src/components/settings/user-settings.tsx
Normal file
174
src/components/settings/user-settings.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
interface NotificationPrefs {
|
||||
reminder_due: boolean;
|
||||
reminder_overdue: boolean;
|
||||
eoi_signed: boolean;
|
||||
eoi_completed: boolean;
|
||||
invoice_overdue: boolean;
|
||||
duplicate_alert: boolean;
|
||||
[key: string]: boolean;
|
||||
}
|
||||
|
||||
export function UserSettings() {
|
||||
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs | null>(null);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [timezone, setTimezone] = useState('');
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfile();
|
||||
void loadNotificationPrefs();
|
||||
}, []);
|
||||
|
||||
async function loadProfile() {
|
||||
const res = await apiFetch<{ data: { user?: { name: string } } }>('/api/v1/me', {
|
||||
method: 'GET',
|
||||
});
|
||||
setDisplayName(res.data.user?.name ?? '');
|
||||
}
|
||||
|
||||
async function loadNotificationPrefs() {
|
||||
try {
|
||||
const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
|
||||
setNotifPrefs(res.data);
|
||||
} catch {
|
||||
// Preferences may not exist yet
|
||||
setNotifPrefs({
|
||||
reminder_due: true,
|
||||
reminder_overdue: true,
|
||||
eoi_signed: true,
|
||||
eoi_completed: true,
|
||||
invoice_overdue: true,
|
||||
duplicate_alert: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
setSaving('profile');
|
||||
setMessage(null);
|
||||
try {
|
||||
await apiFetch('/api/v1/me', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
displayName: displayName || undefined,
|
||||
phone: phone || null,
|
||||
preferences: { timezone: timezone || undefined },
|
||||
},
|
||||
});
|
||||
setMessage('Profile saved');
|
||||
} catch (err: unknown) {
|
||||
setMessage(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotifPref(key: string, value: boolean) {
|
||||
setSaving(key);
|
||||
try {
|
||||
await apiFetch('/api/v1/notifications/preferences', {
|
||||
method: 'PATCH',
|
||||
body: { [key]: value },
|
||||
});
|
||||
setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}
|
||||
|
||||
const NOTIF_LABELS: Record<string, string> = {
|
||||
reminder_due: 'Reminder due',
|
||||
reminder_overdue: 'Reminder overdue',
|
||||
eoi_signed: 'EOI signed by a party',
|
||||
eoi_completed: 'EOI fully completed',
|
||||
invoice_overdue: 'Invoice overdue',
|
||||
duplicate_alert: 'Duplicate client detected',
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
|
||||
|
||||
<div className="mt-6 space-y-6 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profile</CardTitle>
|
||||
<CardDescription>Update your display name and contact info</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-name">Display Name</Label>
|
||||
<Input
|
||||
id="settings-name"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-phone">Phone</Label>
|
||||
<Input
|
||||
id="settings-phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+1 555-0123"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="settings-tz">Timezone</Label>
|
||||
<Input
|
||||
id="settings-tz"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
placeholder="America/Anguilla"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={saveProfile} disabled={saving === 'profile'}>
|
||||
<Save className="mr-1.5 h-4 w-4" />
|
||||
{saving === 'profile' ? 'Saving...' : 'Save Profile'}
|
||||
</Button>
|
||||
{message && <span className="text-sm text-muted-foreground">{message}</span>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
<CardDescription>Choose which notifications you receive</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{notifPrefs &&
|
||||
Object.entries(NOTIF_LABELS).map(([key, label]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<Label>{label}</Label>
|
||||
<Switch
|
||||
checked={notifPrefs[key] ?? true}
|
||||
disabled={saving === key}
|
||||
onCheckedChange={(checked) => toggleNotifPref(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user