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:
2026-04-08 19:45:56 -04:00
parent 4fdd9e3207
commit 8df8ded46c
12 changed files with 779 additions and 53 deletions

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