refactor(doctor-report): entry-point relocated under /settings/export

The doctor-report PDF entry-point used to share the Advanced section
with CSV/JSON exports — three buttons crammed onto one row with no
filter UI in front of them. v1.4.16 phase B7 promotes export to a
first-class settings section, so the doctor-report card now lives
inside `<ExportSection>` (its own card with the date-range dialog
prominently surfaced via "Configure & generate").

`<AdvancedSection>` is reduced to the danger-zone card alone — the
"wipe all data" surface should never live next to a one-click export
button anyway. The matching e2e spec follows the move from
`/settings/advanced` to `/settings/export` and now drives the dialog
through the submit step instead of expecting an inline button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 23:53:17 +02:00
1 parent 2877710b37
commit 94c748d804
3 files changed
+38 -148

No files matched your search

+20 -13
View File
@@ -4,14 +4,16 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* Doctor-report PDF flow. The real flow:
* 1. User clicks "Doctor report (PDF)" on `/settings/advanced`
* 2. Frontend POSTs `/api/doctor-report` to fetch aggregated data
* 3. Frontend dynamic-imports `src/lib/doctor-report-pdf.ts` and
* 1. User clicks "Configure & generate" on `/settings/export`
* (relocated from `/settings/advanced` in v1.4.16 phase B7)
* 2. The dialog opens; user picks dates and submits.
* 3. Frontend POSTs `/api/doctor-report` to fetch aggregated data
* 4. Frontend dynamic-imports `src/lib/doctor-report-pdf.ts` and
* generates the PDF client-side via jsPDF; calls `doc.save(...)`
* which triggers a download.
*
* This spec mocks step 2 (so we don't need 90 days of seeded data) and
* captures step 3's download via Playwright's download API. We then
* This spec mocks step 3 (so we don't need 90 days of seeded data) and
* captures step 4's download via Playwright's download API. We then
* confirm the file is non-empty and starts with `%PDF` magic bytes.
*/
test.describe("doctor report PDF generation", () => {
@@ -59,19 +61,24 @@ test.describe("doctor report PDF generation", () => {
pageErrors.push(`console.error: ${msg.text()}`);
});
await page.goto("/settings/advanced", { waitUntil: "domcontentloaded" });
await page.goto("/settings/export", { waitUntil: "domcontentloaded" });
// The button label varies by locale; match the EN string but keep
// the regex permissive so a future i18n rename still passes if the
// word "report" survives.
const reportBtn = page.getByRole("button", {
name: /doctor report|arztbericht|gesundheitsbericht/i,
});
// The card carries a stable testid so the e2e suite can target it
// without depending on the localised "Configure & generate" label.
const reportBtn = page.getByTestId("export-action-doctor-report");
await expect(reportBtn).toBeVisible({ timeout: 10_000 });
// Trigger the download and capture the resulting Download object.
// Click opens the dialog; the dialog's submit button triggers the PDF
// generation. Trigger the download and capture the resulting Download
// object once the user confirms.
const downloadPromise = page.waitForEvent("download", { timeout: 30_000 });
await reportBtn.click();
// Submit the dialog with the default dates (last 90 days).
const submitBtn = page.getByRole("button", {
name: /generate pdf|pdf generieren/i,
});
await expect(submitBtn).toBeVisible({ timeout: 5_000 });
await submitBtn.click();
const download = await downloadPromise.catch((err) => {
throw new Error(
`Download did not fire. Page errors:\n${pageErrors.join("\n")}\n\nOriginal: ${err}`,
@@ -155,9 +155,15 @@ describe("settings sections — SSR smoke", () => {
expect(html).toContain("API &amp; Tokens");
});
it("<AdvancedSection> renders export + danger-zone cards", () => {
it("<AdvancedSection> renders the danger-zone card only (export moved to /settings/export)", () => {
const html = render(<AdvancedSection />);
// v1.4.16 phase B7: every export path moved out into the dedicated
// `<ExportSection>` so what remains is the irreversible delete-all
// path. The section heading + dangerZone surface are the only
// primary surfaces here now.
expect(html).toContain("Advanced");
expect(html).not.toContain("settings.export");
expect(html).not.toContain("doctorReport");
});
it("<AboutSection> resolves about.* keys in German", () => {
+11 -134
View File
@@ -1,8 +1,8 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Download, Loader2, Trash2 } from "lucide-react";
import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -16,12 +16,17 @@ import {
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";
/**
* Settings → Advanced.
*
* v1.4.16 phase B7: every export path (CSV, JSON, doctor-report PDF)
* moved out into the dedicated `<ExportSection>` under `/settings/export`.
* What stays here is the irreversible danger-zone — the "wipe all my
* data" surface that should never live next to a single-click export
* button.
*/
export function AdvancedSection() {
const { t } = useTranslations();
@@ -42,139 +47,11 @@ export function AdvancedSection() {
</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();