// ui/src/components/settings/NotificationSettings.tsx
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Bell, Banknote, Calendar, ClipboardList, Package } from 'lucide-react';
import { qk } from '@/lib/queryKeys';
import {
getCurrentUser,
getClinicNotificationPrefs,
updateClinicNotificationPrefs,
type NotificationPreferences,
type NotificationEventKey,
type EmailAudience,
type EmailMatrix,
} from '@/lib/serverComm';
type EventRow = {
key: NotificationEventKey;
label: string;
audiences: Partial<Record<EmailAudience, 'on' | 'na'>>; // 'na' = greyed-out cell
};
type Group = { title: string; icon: React.ComponentType<{ className?: string }>; rows: EventRow[] };
const GROUPS: Group[] = [
{
title: 'Appointment lifecycle',
icon: Calendar,
rows: [
{ key: 'appointment.requested', label: 'Appointment requested', audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
{ key: 'appointment.scheduled', label: 'Appointment scheduled', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.confirmed', label: 'Appointment confirmed', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.completed', label: 'Appointment completed', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.cancelled', label: 'Appointment cancelled', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.rescheduled', label: 'Appointment rescheduled', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.noshow', label: 'Patient no-show', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
],
},
{
title: 'Reminders & follow-ups (scheduled)',
icon: Bell,
rows: [
{ key: 'appointment.reminder', label: 'Appointment reminder (24/8/4h)', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.missed_followup', label: 'Missed appointment follow-up', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
{ key: 'appointment.feedback', label: 'Post-visit feedback request', audiences: { patient: 'on', doctor: 'na', admins: 'na', staff: 'na' } },
],
},
{
title: 'Billing & documents',
icon: Banknote,
rows: [
{ key: 'invoice.issued', label: 'Invoice issued', audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
{ key: 'quotation.sent', label: 'Quotation sent', audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
],
},
{
title: 'Treatment plans',
icon: ClipboardList,
rows: [
{ key: 'treatment_plan.created', label: 'Treatment plan created', audiences: { patient: 'on', doctor: 'na', admins: 'on', staff: 'on' } },
{ key: 'treatment_plan.accepted', label: 'Treatment plan accepted', audiences: { patient: 'on', doctor: 'on', admins: 'on', staff: 'on' } },
],
},
{
title: 'Inventory & operations',
icon: Package,
rows: [
{ key: 'inventory.lowstock', label: 'Low-stock alert', audiences: { patient: 'na', doctor: 'na', admins: 'on', staff: 'on' } },
{ key: 'inventory.expiry', label: 'Expiring inventory', audiences: { patient: 'na', doctor: 'na', admins: 'on', staff: 'on' } },
{ key: 'eod.report', label: 'End-of-day report', audiences: { patient: 'na', doctor: 'na', admins: 'on', staff: 'on' } },
],
},
];
const AUDIENCE_COLUMNS: { key: EmailAudience; label: string }[] = [
{ key: 'patient', label: 'Patient' },
{ key: 'doctor', label: 'Assigned doctor' },
{ key: 'admins', label: 'Clinic admins' },
{ key: 'staff', label: 'Clinic staff' },
];
const DEFAULTS_FOR_RESET: EmailMatrix = {
// Same defaults as the server. Keep in sync with server/src/lib/notification-defaults.ts.
'appointment.requested': { patient: true, doctor: false, admins: false, staff: false },
'appointment.scheduled': { patient: true, doctor: true, admins: false, staff: false },
'appointment.confirmed': { patient: true, doctor: true, admins: false, staff: false },
'appointment.completed': { patient: false, doctor: false, admins: false, staff: false },
'appointment.cancelled': { patient: true, doctor: true, admins: false, staff: false },
'appointment.rescheduled': { patient: true, doctor: true, admins: false, staff: false },
'appointment.noshow': { patient: true, doctor: false, admins: false, staff: false },
'appointment.reminder': { patient: true, doctor: false, admins: false, staff: false },
'appointment.missed_followup': { patient: true, doctor: false, admins: false, staff: false },
'appointment.feedback': { patient: true, doctor: false, admins: false, staff: false },
'invoice.issued': { patient: true, doctor: false, admins: true, staff: false },
'quotation.sent': { patient: true, doctor: false, admins: false, staff: false },
'treatment_plan.created': { patient: true, doctor: false, admins: false, staff: false },
'treatment_plan.accepted': { patient: true, doctor: false, admins: true, staff: false },
'inventory.lowstock': { patient: false, doctor: false, admins: true, staff: false },
'inventory.expiry': { patient: false, doctor: false, admins: true, staff: false },
'eod.report': { patient: false, doctor: false, admins: true, staff: false },
};
function effectiveCell(matrix: EmailMatrix | undefined, key: NotificationEventKey, aud: EmailAudience): boolean {
const override = matrix?.[key]?.[aud];
if (typeof override === 'boolean') return override;
return DEFAULTS_FOR_RESET[key][aud];
}
export default function NotificationSettings() {
const [matrix, setMatrix] = useState<EmailMatrix>({});
const [googleUrl, setGoogleUrl] = useState('');
const [savingUrl, setSavingUrl] = useState(false);
const dataQuery = useQuery({
queryKey: qk.clinic.notificationPrefs(),
queryFn: async () => {
const userRes = await getCurrentUser();
const id = (userRes.user as any).clinicId || (userRes.user as any).primaryClinicId;
if (!id) return { clinicId: null as string | null, prefs: {} as NotificationPreferences };
const prefs = await getClinicNotificationPrefs(id);
return { clinicId: id as string, prefs };
},
});
const clinicId = dataQuery.data?.clinicId ?? null;
const loading = dataQuery.isLoading;
useEffect(() => {
if (dataQuery.data?.prefs) {
setMatrix(dataQuery.data.prefs.emailMatrix ?? {});
setGoogleUrl(dataQuery.data.prefs.googleReviewUrl ?? '');
}
}, [dataQuery.data]);
const persist = async (next: EmailMatrix) => {
if (!clinicId) return;
setMatrix(next);
try {
const prefs: NotificationPreferences = {
...(dataQuery.data?.prefs ?? {}),
emailMatrix: next,
googleReviewUrl: googleUrl || undefined,
};
await updateClinicNotificationPrefs(clinicId, prefs);
} catch {
toast.error('Failed to save');
// Revert on failure
setMatrix(matrix);
}
};
const toggleCell = (key: NotificationEventKey, aud: EmailAudience) => {
const current = effectiveCell(matrix, key, aud);
const next: EmailMatrix = {
...matrix,
[key]: { ...(matrix[key] ?? {}), [aud]: !current },
};
void persist(next);
};
const silenceColumnAcross = (aud: EmailAudience) => {
const next: EmailMatrix = { ...matrix };
for (const group of GROUPS) {
for (const row of group.rows) {
if (row.audiences[aud] === 'on') {
next[row.key] = { ...(next[row.key] ?? {}), [aud]: false };
}
}
}
void persist(next);
};
const resetAll = () => void persist({});
const saveGoogleUrl = async () => {
if (!clinicId) return;
setSavingUrl(true);
try {
await updateClinicNotificationPrefs(clinicId, {
...(dataQuery.data?.prefs ?? {}),
emailMatrix: matrix,
googleReviewUrl: googleUrl.trim() || undefined,
});
toast.success('Google Review link saved');
} catch {
toast.error('Failed to save link');
} finally {
setSavingUrl(false);
}
};
if (loading) return <div className="text-sm text-muted-foreground">Loading…</div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
<Bell className="h-5 w-5" /> Email Notifications
</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose who receives each email by audience. Defaults are tuned to reduce inbox noise.
</p>
</div>
<Button variant="outline" size="sm" onClick={resetAll}>Reset to defaults</Button>
</div>
{/* Column-bulk action row */}
<div className="flex flex-wrap gap-2">
{AUDIENCE_COLUMNS.map(c => (
<Button key={c.key} variant="ghost" size="sm" onClick={() => silenceColumnAcross(c.key)}>
Silence “{c.label}” everywhere
</Button>
))}
</div>
{GROUPS.map(group => (
<Card key={group.title}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<group.icon className="h-4 w-4" /> {group.title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="hidden md:block overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-muted-foreground">
<tr>
<th className="text-left font-normal py-2">Event</th>
{AUDIENCE_COLUMNS.map(c => (
<th key={c.key} className="text-center font-normal py-2 px-3">{c.label}</th>
))}
</tr>
</thead>
<tbody>
{group.rows.map(row => (
<tr key={row.key} className="border-t">
<td className="py-3 pr-4">{row.label}</td>
{AUDIENCE_COLUMNS.map(c => (
<td key={c.key} className="text-center py-3 px-3">
{row.audiences[c.key] === 'on' ? (
<Checkbox
checked={effectiveCell(matrix, row.key, c.key)}
onCheckedChange={() => toggleCell(row.key, c.key)}
aria-label={`${row.label} to ${c.label}`}
/>
) : (
<span className="text-muted-foreground">—</span>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: per-event accordion */}
<div className="md:hidden space-y-3">
{group.rows.map(row => (
<div key={row.key} className="border rounded-md p-3">
<div className="font-medium text-sm mb-2">{row.label}</div>
<div className="grid grid-cols-2 gap-2">
{AUDIENCE_COLUMNS.map(c => (
row.audiences[c.key] === 'on' ? (
<label key={c.key} className="flex items-center gap-2 text-sm">
<Checkbox
checked={effectiveCell(matrix, row.key, c.key)}
onCheckedChange={() => toggleCell(row.key, c.key)}
/>
{c.label}
</label>
) : null
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
{/* Google Review URL field (related to the feedback row above) */}
<Card>
<CardHeader>
<CardTitle className="text-base">Google Review link</CardTitle>
<CardDescription>Required for the post-visit feedback email.</CardDescription>
</CardHeader>
<CardContent className="flex gap-2">
<Input value={googleUrl} onChange={e => setGoogleUrl(e.target.value)} placeholder="https://g.page/r/..." />
<Button onClick={saveGoogleUrl} disabled={savingUrl}>Save</Button>
</CardContent>
</Card>
</div>
);
}