mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
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:
1 parent
2877710b37
commit
94c748d804
3 files changed
+38
-148
No files matched your search
+20
-13
@@ -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 & 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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user