Files
temetro/frontend/components/settings/settings-preferences.tsx
T
Khalid Abdi 35f07c508d frontend: block past dates in appointment/invoice pickers; drop stale Settings Features
- Appointment date picker disables days before today.
- Invoice issue-date picker disables past days by default, with an opt-in
  "Back-date" checkbox for recording older invoices (edit mode keeps past
  dates). Added invoices.dialog.backdate to all locales.
- Removed the inert, outdated "Features" section (patient-owned storage /
  require signed toggles) from Settings and its now-unused i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:41:58 +03:00

358 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectItem,
SelectPopup,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { DeleteAccountDialog } from "@/components/settings/delete-account-dialog";
import {
CopyField,
FieldLabel,
SettingsCard,
SettingsSection,
ToggleRow,
} from "@/components/settings/settings-parts";
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import {
getSettings,
saveSettings,
type UserPreferences,
} from "@/lib/settings";
import { notify } from "@/lib/toast";
// Keys into settings.profile.notif.* — each maps to a persisted preference.
const patientNotifications = [
{ titleKey: "newLab", descKey: "newLabDesc" },
{ titleKey: "recordUpdated", descKey: "recordUpdatedDesc" },
{ titleKey: "approvalRequested", descKey: "approvalRequestedDesc" },
{ titleKey: "changeApproved", descKey: "changeApprovedDesc" },
{ titleKey: "newMessage", descKey: "newMessageDesc" },
{ titleKey: "visitScheduled", descKey: "visitScheduledDesc" },
] as const;
const accountNotifications = [
{ titleKey: "pendingApprovals", descKey: "pendingApprovalsDesc" },
{ titleKey: "recordsShared", descKey: "recordsSharedDesc" },
] as const;
// All notification toggles default to on; profile extras default to empty.
const DEFAULT_PREFS: UserPreferences = {
"notif.newLab": true,
"notif.recordUpdated": true,
"notif.approvalRequested": true,
"notif.changeApproved": true,
"notif.newMessage": true,
"notif.visitScheduled": true,
"notif.pendingApprovals": true,
"notif.recordsShared": true,
clinic: "",
contactEmail: "",
};
export function ProfilePanel() {
const { t, i18n } = useTranslation();
const { data: session } = authClient.useSession();
const user = session?.user;
// The active UI language — `i18n.changeLanguage` persists the choice to
// localStorage (the detector's cache), so it survives reloads.
const activeLang = i18n.resolvedLanguage ?? i18n.language;
const [prefs, setPrefs] = useState<UserPreferences>(DEFAULT_PREFS);
const [baseline, setBaseline] = useState<UserPreferences>(DEFAULT_PREFS);
const [name, setName] = useState("");
const [baselineName, setBaselineName] = useState("");
const [saving, setSaving] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// A language picked in the Select but not yet applied — its presence opens the
// confirmation dialog. The Select stays bound to `activeLang`, so cancelling
// (clearing this) automatically reverts the shown selection.
const [pendingLang, setPendingLang] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getSettings()
.then((stored) => {
if (cancelled) return;
const merged = { ...DEFAULT_PREFS, ...stored };
setPrefs(merged);
setBaseline(merged);
})
.catch(() => {
// Keep the defaults; the page still works and Save will retry.
});
return () => {
cancelled = true;
};
}, []);
// Seed the display name from the session once it loads.
useEffect(() => {
if (user?.name) {
setName((prev) => prev || user.name);
setBaselineName((prev) => prev || user.name);
}
}, [user?.name]);
const setPref = (key: string, value: boolean | string) =>
setPrefs((prev) => ({ ...prev, [key]: value }));
const dirty =
name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline);
const save = async () => {
setSaving(true);
try {
const saved = await saveSettings(prefs);
const trimmed = name.trim();
if (trimmed && trimmed !== baselineName) {
const { error } = await authClient.updateUser({ name: trimmed });
if (error) throw new Error(error.message ?? "updateUser failed");
setName(trimmed);
setBaselineName(trimmed);
}
const merged = { ...DEFAULT_PREFS, ...saved };
setPrefs(merged);
setBaseline(merged);
notify.success(
t("settings.profile.savedTitle"),
t("settings.profile.savedBody"),
);
} catch {
notify.error(
t("settings.profile.saveFailedTitle"),
t("settings.profile.saveFailedBody"),
);
} finally {
setSaving(false);
}
};
const initial = (name || user?.name || "?").trim().charAt(0).toUpperCase();
const username =
(user as { username?: string | null } | undefined)?.username ?? null;
return (
<>
<SettingsSection title={t("settings.profile.sectionTitle")}>
<SettingsCard className="space-y-6 p-5">
<CopyField
description={t("settings.profile.clinicianIdDescription")}
label={t("settings.profile.clinicianIdLabel")}
value={user?.id ?? "—"}
/>
<CopyField
description={t("settings.profile.handleDescription")}
label={t("settings.profile.handleLabel")}
value={username ?? user?.email ?? "—"}
/>
<div className="flex items-end gap-4">
<div className="space-y-1.5">
<FieldLabel>{t("settings.profile.avatar")}</FieldLabel>
<Avatar className="size-10 rounded-xl">
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
{initial}
</AvatarFallback>
</Avatar>
</div>
<div className="flex-1 space-y-1.5">
<FieldLabel required>
{t("settings.profile.displayName")}
</FieldLabel>
<Input
onChange={(event) => setName(event.target.value)}
value={name}
/>
</div>
</div>
<div className="space-y-1.5">
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
>
{t("settings.profile.selectSpecialty")}
<ChevronDown className="size-4" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>{t("settings.profile.clinic")}</FieldLabel>
<Input
onChange={(event) => setPref("clinic", event.target.value)}
placeholder={t("settings.profile.clinicPlaceholder")}
value={String(prefs.clinic ?? "")}
/>
</div>
<div className="space-y-1.5">
<FieldLabel required>
{t("settings.profile.contactEmail")}
</FieldLabel>
<Input
onChange={(event) =>
setPref("contactEmail", event.target.value)
}
placeholder={t("settings.profile.contactEmailPlaceholder")}
value={String(prefs.contactEmail ?? "")}
/>
</div>
</div>
<div className="space-y-2.5">
<div className="space-y-0.5">
<FieldLabel>{t("settings.profile.professionalLinks")}</FieldLabel>
<p className="text-xs text-muted-foreground">
{t("settings.profile.professionalLinksHint")}
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
<Plus className="size-4" />
{t("settings.profile.addLink")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.profile.language.description")}
title={t("settings.profile.language.title")}
>
<SettingsCard className="space-y-1.5 p-5">
<FieldLabel>{t("settings.profile.language.label")}</FieldLabel>
<Select
onValueChange={(value) => {
if (value !== activeLang) setPendingLang(value);
}}
value={activeLang}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{supportedLanguages.map((lng) => (
<SelectItem key={lng} value={lng}>
{t(`settings.profile.language.${lng}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</SettingsCard>
</SettingsSection>
<SettingsSection
description={t("settings.profile.patientNotificationsDescription")}
title={t("settings.profile.patientNotifications")}
>
<div className="space-y-3">
{patientNotifications.map((item) => (
<ToggleRow
checked={Boolean(prefs[`notif.${item.titleKey}`])}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
onCheckedChange={(checked) =>
setPref(`notif.${item.titleKey}`, checked)
}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</div>
</SettingsSection>
<SettingsSection
description={t("settings.profile.accountNotificationsDescription")}
title={t("settings.profile.accountNotifications")}
>
<div className="space-y-3">
{accountNotifications.map((item) => (
<ToggleRow
checked={Boolean(prefs[`notif.${item.titleKey}`])}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
onCheckedChange={(checked) =>
setPref(`notif.${item.titleKey}`, checked)
}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</div>
</SettingsSection>
<SettingsSection
description={t("settings.profile.dangerZoneDescription")}
title={t("settings.profile.dangerZone")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.profile.deleteAccount")}
</p>
<p className="text-sm text-muted-foreground">
{t("settings.profile.deleteAccountDescription")}
</p>
</div>
<Button onClick={() => setDeleteOpen(true)} variant="destructive">
{t("settings.profile.delete")}
</Button>
</SettingsCard>
</SettingsSection>
<DeleteAccountDialog onOpenChange={setDeleteOpen} open={deleteOpen} />
<ConfirmDialog
cancelLabel={t("settings.profile.language.cancel")}
confirmLabel={t("settings.profile.language.confirmCta")}
description={
pendingLang
? t("settings.profile.language.confirmBody", {
language: t(`settings.profile.language.${pendingLang}`),
})
: undefined
}
onConfirm={() => {
if (pendingLang) {
void i18n.changeLanguage(pendingLang);
void persistLanguage(pendingLang);
}
}}
onOpenChange={(open) => {
if (!open) setPendingLang(null);
}}
open={pendingLang !== null}
title={t("settings.profile.language.confirmTitle")}
variant="default"
/>
{dirty ? (
<div className="sticky bottom-4 z-10">
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur">
<p className="text-sm text-muted-foreground">
{t("settings.profile.unsavedChanges")}
</p>
<Button disabled={saving} onClick={save} size="sm">
{saving
? t("settings.profile.saving")
: t("settings.profile.saveChanges")}
</Button>
</div>
</div>
) : null}
</>
);
}