import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useEscapeKey } from "../hooks/useEscapeKey"; import { useScrollLock } from "../hooks/useScrollLock"; import type { Medication } from "../types"; import { getMedDisplayName, getPackageSize, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType, } from "../types"; import { MedicationAvatar } from "./MedicationAvatar"; type ReportFormat = "txt" | "md" | "pdf"; interface ReportModalProps { isOpen: boolean; onClose: () => void; medications: Medication[]; } type ReportData = Record< number, { dosesTaken: number; automaticDosesTaken: number; dosesDismissed: number; firstDoseAt: string | null; lastDoseAt: string | null; refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[]; } >; export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) { const { t } = useTranslation(); const [selectedIds, setSelectedIds] = useState>(new Set()); const [format, setFormat] = useState("pdf"); const [generating, setGenerating] = useState(false); const [takenByFilter, setTakenByFilter] = useState>(new Set()); useScrollLock(isOpen); useEscapeKey(isOpen, onClose); // Collect all unique "taken by" people across all medications const allPeople = useMemo(() => { const people = new Set(); for (const med of medications) { if (med.takenBy) { for (const p of med.takenBy) people.add(p); } } return Array.from(people).sort(); }, [medications]); // Filtered medications based on takenBy filter const filteredMeds = useMemo(() => { if (takenByFilter.size === 0) return medications; return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p))); }, [medications, takenByFilter]); const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]); const obsoleteMeds = useMemo(() => filteredMeds.filter((m) => m.isObsolete), [filteredMeds]); const togglePerson = useCallback((person: string) => { setTakenByFilter((prev) => { const next = new Set(prev); if (next.has(person)) next.delete(person); else next.add(person); return next; }); }, []); const selectAllPeople = useCallback(() => { setTakenByFilter(new Set()); }, []); // Reset selection when modal opens or filter changes useEffect(() => { if (isOpen) { setSelectedIds(new Set(filteredMeds.map((m) => m.id))); } }, [isOpen, filteredMeds]); // Reset everything when modal opens useEffect(() => { if (isOpen) { setTakenByFilter(new Set()); setFormat("pdf"); setGenerating(false); } }, [isOpen]); const toggleMed = useCallback((id: number) => { setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const selectAll = useCallback(() => { setSelectedIds(new Set(filteredMeds.map((m) => m.id))); }, [filteredMeds]); const deselectAll = useCallback(() => { setSelectedIds(new Set()); }, []); const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]); async function handleGenerate() { if (selectedIds.size === 0) return; setGenerating(true); try { // Fetch report data from backend const res = await fetch("/api/medications/report-data", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ medicationIds: Array.from(selectedIds) }), credentials: "include", }); if (!res.ok) throw new Error("Failed to fetch report data"); const reportData = (await res.json()) as ReportData; if (format === "pdf") { const imageMap = await fetchMedImages(selectedMeds); const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null; openPrintView(selectedMeds, reportData, t, imageMap, filterArr); } else { const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null; const content = generateTextReport(selectedMeds, reportData, format, t, filterArr); downloadFile(content, format); } onClose(); } catch { // Stay open on error so user can retry } finally { setGenerating(false); } } if (!isOpen) return null; return (
{ if (e.key !== "Escape") e.stopPropagation(); }} >
e.stopPropagation()} onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }} >

{t("report.title")}

{t("report.description")}

{/* Person filter */} {allPeople.length > 1 && (

{t("report.filterByPerson")}

{allPeople.map((person) => ( ))}
)} {/* Medication selection */}
{selectedIds.size} / {filteredMeds.length}
{activeMeds.length > 0 && (

{t("report.activeMeds")}

{activeMeds.map((med) => ( ))}
)} {obsoleteMeds.length > 0 && (

{t("report.obsoleteMeds")}

{obsoleteMeds.map((med) => ( ))}
)}
{/* Format selection */}

{t("report.format")}

{/* Actions */}
); } // ─── Report generation helpers ─── type TFn = (key: string, opts?: Record) => string; function fmtDate(iso: string | null | undefined): string { if (!iso) return "-"; const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})/); if (!m) return "-"; return `${m[3]}.${m[2]}.${m[1]}`; } function fmtDateTime(iso: string | null | undefined): string { if (!iso) return "-"; const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); if (!m) return `${fmtDate(iso)}`; return `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}`; } function getTubeUnitKey(med: Medication): "form.ml" | "blisters.applications" { if (isLiquidContainerPackageType(med.packageType)) return "form.ml"; return med.medicationForm === "liquid" ? "form.ml" : "blisters.applications"; } function getUsageText(med: Medication, usage: number, t: TFn): string { if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) { return `${usage} ${t(getTubeUnitKey(med))}`; } return `${usage} ${usage === 1 ? t("common.pill") : t("common.pills")}`; } function getTotalCapacityLabel(med: Medication, t: TFn): string { if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) { return t("form.totalAmountLabel", { unit: t(getTubeUnitKey(med)) }); } return t("report.docTotalCapacity"); } function getCurrentStockText(med: Medication, t: TFn): string { if (isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType)) { return `${getPackageSize(med)} ${t(getTubeUnitKey(med))}`; } return `${getPackageSize(med)} ${t("common.pills")}`; } function getReportPackageTypeLabel(med: Medication, t: TFn): string { if (isTubePackageType(med.packageType)) return t("report.docTube"); if (isLiquidContainerPackageType(med.packageType)) return t("form.packageTypeLiquidContainer"); if (isAmountBasedPackageType(med.packageType)) return t("report.docBottle"); return t("report.docBlister"); } function generateTextReport( meds: Medication[], reportData: ReportData, fmt: "txt" | "md", t: TFn, personFilter: string[] | null ): string { const lines: string[] = []; const sep = fmt === "md" ? "---" : "═".repeat(60); const h1 = (s: string) => (fmt === "md" ? `# ${s}` : s); const h2 = (s: string) => (fmt === "md" ? `## ${s}` : s); const h3 = (s: string) => (fmt === "md" ? `### ${s}` : ` ${s}`); const bold = (s: string) => (fmt === "md" ? `**${s}**` : s); const item = (label: string, value: string) => (fmt === "md" ? `- ${bold(label)}: ${value}` : ` ${label}: ${value}`); lines.push(h1(t("report.docTitle"))); lines.push(`${t("report.docGenerated")}: ${fmtDate(new Date().toISOString())}`); lines.push(""); for (const med of meds) { lines.push(sep); lines.push(""); const title = med.isObsolete ? `${getMedDisplayName(med)} (${t("report.docStatusObsolete")})` : getMedDisplayName(med); lines.push(h2(title)); lines.push(""); // General lines.push(h3(t("report.docGeneral"))); if (med.name) lines.push(item(t("report.docCommercialName"), med.name)); if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName)); if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", "))); lines.push( item(t("report.docStatus"), med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive")) ); if (med.medicationStartDate) lines.push(item(t("report.docStartDate"), fmtDate(med.medicationStartDate))); if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), fmtDate(med.obsoleteAt))); lines.push(""); // Package / Stock lines.push(h3(t("report.docPackage"))); lines.push(item(t("report.docPackageType"), getReportPackageTypeLabel(med, t))); if (!isAmountBasedPackageType(med.packageType)) { lines.push(item(t("report.docPacks"), String(med.packCount))); lines.push(item(t("report.docBlistersPerPack"), String(med.blistersPerPack))); lines.push(item(t("report.docPillsPerBlister"), String(med.pillsPerBlister))); if (med.looseTablets > 0) lines.push(item(t("report.docLoosePills"), String(med.looseTablets))); } else { lines.push(item(getTotalCapacityLabel(med, t), String(med.totalPills ?? med.looseTablets))); } lines.push(item(t("report.docCurrentStock"), getCurrentStockText(med, t))); if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) lines.push(item(t("report.docDosePerPill"), `${med.pillWeightMg} ${med.doseUnit ?? "mg"}`)); if (med.expiryDate) lines.push(item(t("report.docExpiryDate"), fmtDate(med.expiryDate))); if (med.notes) lines.push(item(t("report.docNotes"), med.notes)); lines.push(""); // Intake Schedule const allIntakes = med.intakes ?? med.blisters; const intakes = personFilter ? allIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string)) : allIntakes; if (intakes?.length) { lines.push(h3(t("report.docIntakeSchedule"))); for (const intake of intakes) { let entry = getUsageText(med, intake.usage, t); entry += ` ${intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}`; entry += ` ${t("form.blisters.from")} ${fmtDateTime(intake.start)}`; if ("takenBy" in intake && intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`; if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`; lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`); } lines.push(""); } // Prescription if (med.prescriptionEnabled) { lines.push(h3(t("report.docPrescription"))); lines.push(item(t("report.docAuthorizedRefills"), String(med.prescriptionAuthorizedRefills ?? 0))); lines.push(item(t("report.docRemainingRefills"), String(med.prescriptionRemainingRefills ?? 0))); if (med.prescriptionExpiryDate) lines.push(item(t("report.docPrescriptionExpiry"), fmtDate(med.prescriptionExpiryDate))); lines.push(""); } // Dose tracking data const data = reportData[med.id]; if (data) { lines.push(h3(t("report.docIntakeHistory"))); if (data.dosesTaken > 0 || data.dosesDismissed > 0) { lines.push(item(t("report.docDosesTaken"), String(data.dosesTaken))); if (data.automaticDosesTaken > 0) { lines.push(item(`🤖 ${t("report.docDosesTakenAutomatic")}`, String(data.automaticDosesTaken))); } if (data.dosesDismissed > 0) lines.push(item(t("report.docDosesDismissed"), String(data.dosesDismissed))); if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), fmtDate(data.firstDoseAt))); if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), fmtDate(data.lastDoseAt))); } else { lines.push(fmt === "md" ? `- ${t("report.docNoDoses")}` : ` ${t("report.docNoDoses")}`); } lines.push(""); // Refill history if (data.refills.length > 0) { lines.push(h3(t("report.docRefillHistory"))); for (const r of data.refills) { let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.loosePillsAdded} ${isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills")}`; if (r.usedPrescription) entry += ` ${t("report.docRefillPrescription")}`; lines.push(fmt === "md" ? `- ${entry}` : ` • ${entry}`); } lines.push(""); } } } lines.push(sep); return lines.join("\n"); } function downloadFile(content: string, format: "txt" | "md") { const mimeType = format === "md" ? "text/markdown" : "text/plain"; const blob = new Blob([content], { type: `${mimeType};charset=utf-8` }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const dateStr = new Date().toISOString().slice(0, 10); a.href = url; a.download = `medassist-report-${dateStr}.${format}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } type ImageMap = Record; async function fetchMedImages(meds: Medication[]): Promise { const map: ImageMap = {}; const fetches = meds .filter((m) => m.imageUrl) .map(async (m) => { try { const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" }); if (!res.ok) return; const blob = await res.blob(); const dataUrl = await new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); map[m.id] = dataUrl; } catch { // Skip image on error } }); await Promise.all(fetches); return map; } function openPrintView( meds: Medication[], reportData: ReportData, t: TFn, imageMap: ImageMap, personFilter: string[] | null ) { const w = window.open("", "_blank"); if (!w) return; const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter); w.document.write(html); w.document.close(); w.onload = () => setTimeout(() => w.print(), 300); } function escHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function buildPrintHtml( meds: Medication[], reportData: ReportData, t: TFn, imageMap: ImageMap, personFilter: string[] | null ): string { const sections: string[] = []; for (const med of meds) { const data = reportData[med.id]; const intakes = med.intakes ?? med.blisters; const displayName = getMedDisplayName(med); const title = med.isObsolete ? `${escHtml(displayName)} ${escHtml(t("report.docStatusObsolete"))}` : escHtml(displayName); let s = `
`; const imgDataUrl = imageMap[med.id]; // Title with generic name subtitle s += `

${title}

`; if (med.name && med.genericName) s += `

${escHtml(med.genericName)}

`; // Build general info table rows const generalRows: string[] = []; if (med.name) generalRows.push( `${escHtml(t("report.docCommercialName"))}${escHtml(med.name)}` ); if (med.genericName) generalRows.push( `${escHtml(t("report.docGenericName"))}${escHtml(med.genericName)}` ); if (med.takenBy?.length) generalRows.push( `${escHtml(t("report.docTakenBy"))}${escHtml(med.takenBy.join(", "))}` ); generalRows.push( `${escHtml(t("report.docStatus"))}${escHtml(med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))}` ); if (med.medicationStartDate) generalRows.push( `${escHtml(t("report.docStartDate"))}${fmtDate(med.medicationStartDate)}` ); if (med.isObsolete && med.obsoleteAt) generalRows.push( `${escHtml(t("report.docObsoleteSince"))}${fmtDate(med.obsoleteAt)}` ); const generalTable = `

${escHtml(t("report.docGeneral"))}

${generalRows.join("")}
`; if (imgDataUrl) { s += `
${escHtml(displayName)}
${generalTable}
`; } else { s += generalTable; } // Package / Stock s += `

${escHtml(t("report.docPackage"))}

`; s += ``; s += ``; if (!isAmountBasedPackageType(med.packageType)) { s += ``; s += ``; s += ``; if (med.looseTablets > 0) s += ``; } else { s += ``; } s += ``; if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg) s += ``; if (med.expiryDate) s += ``; if (med.notes) s += ``; s += `
${escHtml(t("report.docPackageType"))}${escHtml(getReportPackageTypeLabel(med, t))}
${escHtml(t("report.docPacks"))}${med.packCount}
${escHtml(t("report.docBlistersPerPack"))}${med.blistersPerPack}
${escHtml(t("report.docPillsPerBlister"))}${med.pillsPerBlister}
${escHtml(t("report.docLoosePills"))}${med.looseTablets}
${escHtml(getTotalCapacityLabel(med, t))}${med.totalPills ?? med.looseTablets}
${escHtml(t("report.docCurrentStock"))}${escHtml(getCurrentStockText(med, t))}
${escHtml(t("report.docDosePerPill"))}${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}
${escHtml(t("report.docExpiryDate"))}${fmtDate(med.expiryDate)}
${escHtml(t("report.docNotes"))}${escHtml(med.notes)}
`; // Intake Schedule const allPrintIntakes = intakes; const filteredPrintIntakes = personFilter ? allPrintIntakes?.filter((i) => "takenBy" in i && personFilter.includes(i.takenBy as string)) : allPrintIntakes; if (filteredPrintIntakes?.length) { s += `

${escHtml(t("report.docIntakeSchedule"))}

`; s += `
    `; for (const intake of filteredPrintIntakes) { let entry = escHtml(getUsageText(med, intake.usage, t)); entry += ` ${escHtml(intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every }))}`; entry += ` ${escHtml(t("form.blisters.from"))} ${fmtDateTime(intake.start)}`; if ("takenBy" in intake && intake.takenBy) entry += ` (${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})`; if ("intakeRemindersEnabled" in intake && intake.intakeRemindersEnabled) entry += ` 🔔`; s += `
  • ${entry}
  • `; } s += `
`; } // Prescription if (med.prescriptionEnabled) { s += `

${escHtml(t("report.docPrescription"))}

`; s += ``; s += ``; s += ``; if (med.prescriptionExpiryDate) s += ``; s += `
${escHtml(t("report.docAuthorizedRefills"))}${med.prescriptionAuthorizedRefills ?? 0}
${escHtml(t("report.docRemainingRefills"))}${med.prescriptionRemainingRefills ?? 0}
${escHtml(t("report.docPrescriptionExpiry"))}${fmtDate(med.prescriptionExpiryDate)}
`; } // Intake history if (data) { s += `

${escHtml(t("report.docIntakeHistory"))}

`; if (data.dosesTaken > 0 || data.dosesDismissed > 0) { s += ``; s += ``; if (data.automaticDosesTaken > 0) { s += ``; } if (data.dosesDismissed > 0) s += ``; if (data.firstDoseAt) s += ``; if (data.lastDoseAt) s += ``; s += `
${escHtml(t("report.docDosesTaken"))}${data.dosesTaken}
${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}${data.automaticDosesTaken}
${escHtml(t("report.docDosesDismissed"))}${data.dosesDismissed}
${escHtml(t("report.docFirstDose"))}${fmtDate(data.firstDoseAt)}
${escHtml(t("report.docLastDose"))}${fmtDate(data.lastDoseAt)}
`; } else { s += `

${escHtml(t("report.docNoDoses"))}

`; } // Refill history if (data.refills.length > 0) { s += `

${escHtml(t("report.docRefillHistory"))}

`; s += `
    `; for (const r of data.refills) { let entry = `${fmtDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.loosePillsAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`; if (r.usedPrescription) entry += ` ${escHtml(t("report.docRefillPrescription"))}`; s += `
  • ${entry}
  • `; } s += `
`; } } s += `
`; sections.push(s); } return ` ${escHtml(t("report.docTitle"))}

${escHtml(t("report.docTitle"))}

${escHtml(t("report.docGenerated"))}: ${fmtDate(new Date().toISOString())}

${sections.join("\n")} `; } export default ReportModal;