Files
HealthLog/src/components/settings/advanced-section.tsx
T
Marc-André BombeckandClaude Opus 4.7 28467b2590 feat(doctor-report): practice name on cover page (persisted as user preference)
Marc wanted the addressee visible at first glance on the printout —
"Praxis Dr. Müller" should be the first thing the GP sees, so the
report doubles as a polite opening line.

- New optional free-text field "Praxis / clinic name" in the export
  dialog. The dialog pre-fills it with the user's last-used value so
  recurring exports don't require re-typing.
- Persisted as a user preference: new `User.lastReportPracticeName`
  column (migration `0031_user_last_report_practice_name`). The value
  is updated only on a successful POST that supplied a non-empty
  string — passing `null` does NOT clear the stored preference so the
  value sticks across years of follow-ups. Best-effort write: a DB
  failure here MUST NOT break the report (caught + ignored).
- `sanitisePracticeName()` enforces a strict envelope upstream: strips
  ASCII C0 + DEL controls (which jsPDF can't render), collapses
  whitespace, and hard-caps at 120 characters. Defends the PDF layout
  from runaway input + protects the PDF text stream from
  control-character corruption.
- PDF cover prints the line in 11pt bold above the separator when set;
  omitted entirely when `null` so the cover stays compact for
  self-archive use. Both client (`doctor-report-pdf.ts`) and server
  (`doctor-report-pdf-core.ts`) renderers carry the change so the
  visual output matches whichever path the user takes (browser
  download vs iOS Safari fallback).
- `/api/auth/me` now echoes `lastReportPracticeName` so the dialog
  pre-fills without an extra round-trip.
- Audit-log + Wide-Event annotations record `practiceNameProvided:
  boolean` (NOT the value itself — practice names are personal and we
  already store one canonical copy on the user record).

i18n: 6 new keys (`doctorReport.practiceLabel`, `doctorReport.dialog.{practiceLabel,practicePlaceholder}`)
in EN + DE.

Tests: 12 new unit cases covering sanitisation (whitespace, controls,
length cap, type guard), PDF cover rendering (DE label "Praxis:", EN
"Practice:", omission when null), API persistence (prisma.user.update
called with the right shape, audit detail flag set). 965 / 965 unit
pass, 31 / 31 integration pass. Lint 0 errors. Typecheck clean for B6
files (pre-existing dashboard-layout.test.ts errors are A4 territory).
v1.4.15 phase B6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:36:47 +02:00

270 lines
8.4 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
DoctorReportDialog,
type DoctorReportSubmitPayload,
} from "@/components/doctor-report/doctor-report-dialog";
import { useTranslations } from "@/lib/i18n/context";
export function AdvancedSection() {
const { t } = useTranslations();
return (
<section
aria-labelledby="settings-section-advanced-title"
className="space-y-6"
>
<header className="space-y-1">
<h1
id="settings-section-advanced-title"
className="text-2xl font-semibold tracking-tight"
>
{t("settings.sections.advanced.title")}
</h1>
<p className="text-muted-foreground text-sm">
{t("settings.sections.advanced.description")}
</p>
</header>
<ExportCard />
<DataResetCard />
</section>
);
}
function ExportCard() {
const { t, locale } = useTranslations();
const [exporting, setExporting] = useState(false);
const [generatingReport, setGeneratingReport] = useState(false);
const [reportDialogOpen, setReportDialogOpen] = useState(false);
const [lastPracticeName, setLastPracticeName] = useState<string | null>(null);
// Fetch the previously-stored practice name so the dialog can pre-fill it.
// `useEffect` is the right place for a one-shot side-effect on mount; the
// ESLint `set-state-in-effect` rule is fine here because we are not
// setting state on every render — we are reacting to a fetch.
useEffect(() => {
let cancelled = false;
fetch("/api/auth/me")
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (cancelled || !json) return;
const name = json?.data?.lastReportPracticeName;
if (typeof name === "string") setLastPracticeName(name);
})
.catch(() => {
// Best-effort; the dialog still works without the pre-fill.
});
return () => {
cancelled = true;
};
}, []);
async function handleExport(format: "csv" | "json") {
setExporting(true);
try {
const res = await fetch(`/api/export?format=${format}&type=all`);
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-export-${new Date().toISOString().slice(0, 10)}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} finally {
setExporting(false);
}
}
async function handleDoctorReport(payload: DoctorReportSubmitPayload) {
setGeneratingReport(true);
try {
const res = await fetch("/api/doctor-report", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
startDate: payload.startDate,
endDate: payload.endDate,
practiceName: payload.practiceName,
}),
});
if (!res.ok) return;
const json = await res.json();
const { generateDoctorReportPDF } =
await import("@/lib/doctor-report-pdf");
const doc = generateDoctorReportPDF(json.data, { t, locale });
const fileSlug = locale === "de" ? "gesundheitsbericht" : "health-report";
doc.save(`${fileSlug}-${new Date().toISOString().slice(0, 10)}.pdf`);
// Remember the last-used practice name so the next open of the dialog
// pre-fills it without a roundtrip to /api/auth/me.
if (payload.practiceName) setLastPracticeName(payload.practiceName);
setReportDialogOpen(false);
} finally {
setGeneratingReport(false);
}
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
<h2 className="text-lg font-semibold">{t("settings.export")}</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.exportDescription")}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleExport("json")}
disabled={exporting}
>
{exporting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t("settings.exportJson")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleExport("csv")}
disabled={exporting}
>
{exporting && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
{t("settings.exportCsv")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setReportDialogOpen(true)}
disabled={generatingReport}
>
{generatingReport && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
)}
{t("settings.doctorReport")}
</Button>
</div>
<DoctorReportDialog
open={reportDialogOpen}
onOpenChange={setReportDialogOpen}
defaultPracticeName={lastPracticeName}
onSubmit={handleDoctorReport}
/>
</div>
);
}
function DataResetCard() {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [deleting, setDeleting] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
async function handleDeleteAllData() {
setDeleting(true);
setMsg(null);
setMsgType(null);
try {
const res = await fetch("/api/settings/data", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirm: "DELETE" }),
});
const json = await res.json();
if (!res.ok) {
setMsg(json.error || t("settings.dangerZoneDeleteFailed"));
setMsgType("error");
return;
}
await queryClient.invalidateQueries();
setMsg(t("settings.dangerZoneSuccess"));
setMsgType("success");
} catch {
setMsg(t("settings.dangerZoneDeleteFailed"));
setMsgType("error");
} finally {
setDeleting(false);
}
}
return (
<div className="bg-card border-border rounded-xl border p-6">
<div className="flex items-center gap-2">
<AlertTriangle className="text-destructive h-5 w-5" />
<h2 className="text-destructive text-lg font-semibold">
{t("settings.dangerZone")}
</h2>
</div>
<p className="text-muted-foreground mt-1 text-xs">
{t("settings.dangerZoneDescription")}
</p>
<div className="mt-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={deleting}>
{deleting ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="mr-1 h-3.5 w-3.5" />
)}
{t("settings.dangerZone")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("settings.dangerZoneConfirm")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("settings.dangerZoneConfirmDescription")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteAllData}
>
{t("settings.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{msg && (
<p
role="alert"
className={`mt-3 text-sm ${msgType === "success" ? "text-dracula-green" : "text-destructive"}`}
>
{msg}
</p>
)}
</div>
);
}