diff --git a/e2e/doctor-report.spec.ts b/e2e/doctor-report.spec.ts index e9524688d..3abf401a7 100644 --- a/e2e/doctor-report.spec.ts +++ b/e2e/doctor-report.spec.ts @@ -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}`, diff --git a/src/components/settings/__tests__/sections.test.tsx b/src/components/settings/__tests__/sections.test.tsx index 4f2769e8c..51e6ff0db 100644 --- a/src/components/settings/__tests__/sections.test.tsx +++ b/src/components/settings/__tests__/sections.test.tsx @@ -155,9 +155,15 @@ describe("settings sections — SSR smoke", () => { expect(html).toContain("API & Tokens"); }); - it(" renders export + danger-zone cards", () => { + it(" renders the danger-zone card only (export moved to /settings/export)", () => { const html = render(); + // v1.4.16 phase B7: every export path moved out into the dedicated + // `` 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(" resolves about.* keys in German", () => { diff --git a/src/components/settings/advanced-section.tsx b/src/components/settings/advanced-section.tsx index 675743833..70d4c373e 100644 --- a/src/components/settings/advanced-section.tsx +++ b/src/components/settings/advanced-section.tsx @@ -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 `` 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() {

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

{t("settings.export")}

-
-

- {t("settings.exportDescription")} -

-
- - - -
- -
- ); -} - function DataResetCard() { const { t } = useTranslations(); const queryClient = useQueryClient();