Files
medassist-ng/frontend/src/components/ReportModal.tsx
T

714 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
getMedTotal,
getStockDisplayCapacity,
isAmountBasedPackageType,
isLiquidContainerPackageType,
isTubePackageType,
} from "../types";
import { formatDate, formatDateTime } from "../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
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;
dosesSkipped: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: {
packsAdded: number;
loosePillsAdded?: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
}
>;
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [format, setFormat] = useState<ReportFormat>("pdf");
const [generating, setGenerating] = useState(false);
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
useScrollLock(isOpen);
useEscapeKey(isOpen, onClose);
// Collect all unique "taken by" people across all medications
const allPeople = useMemo(() => {
const people = new Set<string>();
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),
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
}),
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 (
<div
className="modal-overlay"
onClick={onClose}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
>
<div
className="modal-content report-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
>
<button className="modal-close" onClick={onClose}>
×
</button>
<h2 className="report-modal-title">{t("report.title")}</h2>
<p className="report-modal-desc">{t("report.description")}</p>
{/* Person filter */}
{allPeople.length > 1 && (
<div className="report-person-filter">
<h4>{t("report.filterByPerson")}</h4>
<div className="report-format-options">
<label className={`report-format-option${takenByFilter.size === 0 ? " selected" : ""}`}>
<input type="checkbox" checked={takenByFilter.size === 0} onChange={selectAllPeople} />
<span>{t("report.allPeople")}</span>
</label>
{allPeople.map((person) => (
<label key={person} className={`report-format-option${takenByFilter.has(person) ? " selected" : ""}`}>
<input type="checkbox" checked={takenByFilter.has(person)} onChange={() => togglePerson(person)} />
<span>{person}</span>
</label>
))}
</div>
</div>
)}
{/* Medication selection */}
<div className="report-selection">
<div className="report-selection-header">
<button
type="button"
className="ghost small"
onClick={selectedIds.size === filteredMeds.length ? deselectAll : selectAll}
>
{selectedIds.size === filteredMeds.length ? t("report.deselectAll") : t("report.selectAll")}
</button>
<span className="report-selection-count">
{selectedIds.size} / {filteredMeds.length}
</span>
</div>
{activeMeds.length > 0 && (
<div className="report-group">
<h4 className="report-group-title">{t("report.activeMeds")}</h4>
<div className="report-med-list">
{activeMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name">
{getMedDisplayName(med)}
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
</div>
</div>
)}
{obsoleteMeds.length > 0 && (
<div className="report-group">
<h4 className="report-group-title">{t("report.obsoleteMeds")}</h4>
<div className="report-med-list">
{obsoleteMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name obsolete-name">
{getMedDisplayName(med)}
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
</div>
</div>
)}
</div>
{/* Format selection */}
<div className="report-format">
<h4>{t("report.format")}</h4>
<div className="report-format-options">
<label className={`report-format-option${format === "pdf" ? " selected" : ""}`}>
<input
type="radio"
name="format"
value="pdf"
checked={format === "pdf"}
onChange={() => setFormat("pdf")}
/>
<span>{t("report.formatPdf")}</span>
</label>
<label className={`report-format-option${format === "txt" ? " selected" : ""}`}>
<input
type="radio"
name="format"
value="txt"
checked={format === "txt"}
onChange={() => setFormat("txt")}
/>
<span>{t("report.formatTxt")}</span>
</label>
<label className={`report-format-option${format === "md" ? " selected" : ""}`}>
<input type="radio" name="format" value="md" checked={format === "md"} onChange={() => setFormat("md")} />
<span>{t("report.formatMd")}</span>
</label>
</div>
</div>
{/* Actions */}
<div className="report-actions">
<button type="button" className="ghost" onClick={onClose}>
{t("common.close")}
</button>
<button
type="button"
className="primary"
onClick={handleGenerate}
disabled={selectedIds.size === 0 || generating}
>
{generating ? t("report.generating") : t("report.generate")}
</button>
</div>
</div>
</div>
);
}
// ─── Report generation helpers ───
type TFn = (key: string, opts?: Record<string, unknown>) => string;
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 `${getMedTotal(med)} ${t(getTubeUnitKey(med))}`;
}
return `${getMedTotal(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")}: ${formatDate(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"), formatDate(med.medicationStartDate)));
if (med.isObsolete && med.obsoleteAt) lines.push(item(t("report.docObsoleteSince"), formatDate(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(getStockDisplayCapacity(med))));
}
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"), formatDate(med.expiryDate)));
if (med.notes) lines.push(item(t("report.docNotes"), med.notes));
lines.push("");
// Intake Schedule
const allIntakes = getMedicationIntakes(med);
const intakes = personFilter
? allIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allIntakes;
if (intakes?.length) {
lines.push(h3(t("report.docIntakeSchedule")));
for (const intake of intakes) {
let entry = getUsageText(med, intake.usage, t);
entry += ` ${getIntakeFrequencyText(intake, t)}`;
entry += ` ${t("form.blisters.from")} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` (${t("report.docIntakeTakenBy", { person: intake.takenBy })})`;
if (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"), formatDate(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.dosesSkipped > 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.dosesSkipped > 0) lines.push(item(t("report.docDosesSkipped"), String(data.dosesSkipped)));
if (data.firstDoseAt) lines.push(item(t("report.docFirstDose"), formatDate(data.firstDoseAt)));
if (data.lastDoseAt) lines.push(item(t("report.docLastDose"), formatDate(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 = `${formatDate(r.refillDate)}: +${r.packsAdded} ${t("report.docPacks")}, +${r.quantityAdded} ${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<number, string>;
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
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<string>((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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
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 = getMedicationIntakes(med);
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
: escHtml(displayName);
let s = `<div class="med-section">`;
const imgDataUrl = imageMap[med.id];
// Title with generic name subtitle
s += `<h2>${title}</h2>`;
if (med.name && med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
// Build general info table rows
const generalRows: string[] = [];
if (med.name)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
);
if (med.genericName)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
);
if (med.takenBy?.length)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docTakenBy"))}</td><td>${escHtml(med.takenBy.join(", "))}</td></tr>`
);
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStatus"))}</td><td>${escHtml(med.isObsolete ? t("report.docStatusObsolete") : t("report.docStatusActive"))}</td></tr>`
);
if (med.medicationStartDate)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docStartDate"))}</td><td>${formatDate(med.medicationStartDate)}</td></tr>`
);
if (med.isObsolete && med.obsoleteAt)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docObsoleteSince"))}</td><td>${formatDate(med.obsoleteAt)}</td></tr>`
);
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
if (imgDataUrl) {
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(displayName)}" /><div class="med-overview-info">${generalTable}</div></div>`;
} else {
s += generalTable;
}
// Package / Stock
s += `<h3>${escHtml(t("report.docPackage"))}</h3>`;
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docPackageType"))}</td><td>${escHtml(getReportPackageTypeLabel(med, t))}</td></tr>`;
if (!isAmountBasedPackageType(med.packageType)) {
s += `<tr><td class="label">${escHtml(t("report.docPacks"))}</td><td>${med.packCount}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docBlistersPerPack"))}</td><td>${med.blistersPerPack}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docPillsPerBlister"))}</td><td>${med.pillsPerBlister}</td></tr>`;
if (med.looseTablets > 0)
s += `<tr><td class="label">${escHtml(t("report.docLoosePills"))}</td><td>${med.looseTablets}</td></tr>`;
} else {
s += `<tr><td class="label">${escHtml(getTotalCapacityLabel(med, t))}</td><td>${getStockDisplayCapacity(med)}</td></tr>`;
}
s += `<tr><td class="label">${escHtml(t("report.docCurrentStock"))}</td><td>${escHtml(getCurrentStockText(med, t))}</td></tr>`;
if (!isTubePackageType(med.packageType) && !isLiquidContainerPackageType(med.packageType) && med.pillWeightMg)
s += `<tr><td class="label">${escHtml(t("report.docDosePerPill"))}</td><td>${med.pillWeightMg} ${escHtml(med.doseUnit ?? "mg")}</td></tr>`;
if (med.expiryDate)
s += `<tr><td class="label">${escHtml(t("report.docExpiryDate"))}</td><td>${formatDate(med.expiryDate)}</td></tr>`;
if (med.notes)
s += `<tr><td class="label">${escHtml(t("report.docNotes"))}</td><td>${escHtml(med.notes)}</td></tr>`;
s += `</tbody></table>`;
// Intake Schedule
const allPrintIntakes = intakes;
const filteredPrintIntakes = personFilter
? allPrintIntakes?.filter((intake) => intake.takenBy && personFilter.includes(intake.takenBy))
: allPrintIntakes;
if (filteredPrintIntakes?.length) {
s += `<h3>${escHtml(t("report.docIntakeSchedule"))}</h3>`;
s += `<ul>`;
for (const intake of filteredPrintIntakes) {
let entry = escHtml(getUsageText(med, intake.usage, t));
entry += ` ${escHtml(getIntakeFrequencyText(intake, t))}`;
entry += ` ${escHtml(t("form.blisters.from"))} ${formatDateTime(intake.start)}`;
if (intake.takenBy) entry += ` <em>(${escHtml(t("report.docIntakeTakenBy", { person: intake.takenBy }))})</em>`;
if (intake.intakeRemindersEnabled) entry += ` 🔔`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
}
// Prescription
if (med.prescriptionEnabled) {
s += `<h3>${escHtml(t("report.docPrescription"))}</h3>`;
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docAuthorizedRefills"))}</td><td>${med.prescriptionAuthorizedRefills ?? 0}</td></tr>`;
s += `<tr><td class="label">${escHtml(t("report.docRemainingRefills"))}</td><td>${med.prescriptionRemainingRefills ?? 0}</td></tr>`;
if (med.prescriptionExpiryDate)
s += `<tr><td class="label">${escHtml(t("report.docPrescriptionExpiry"))}</td><td>${formatDate(med.prescriptionExpiryDate)}</td></tr>`;
s += `</tbody></table>`;
}
// Intake history
if (data) {
s += `<h3>${escHtml(t("report.docIntakeHistory"))}</h3>`;
if (data.dosesTaken > 0 || data.dosesSkipped > 0) {
s += `<table><tbody>`;
s += `<tr><td class="label">${escHtml(t("report.docDosesTaken"))}</td><td>${data.dosesTaken}</td></tr>`;
if (data.automaticDosesTaken > 0) {
s += `<tr><td class="label">${escHtml(`🤖 ${t("report.docDosesTakenAutomatic")}`)}</td><td>${data.automaticDosesTaken}</td></tr>`;
}
if (data.dosesSkipped > 0)
s += `<tr><td class="label">${escHtml(t("report.docDosesSkipped"))}</td><td>${data.dosesSkipped}</td></tr>`;
if (data.firstDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docFirstDose"))}</td><td>${formatDate(data.firstDoseAt)}</td></tr>`;
if (data.lastDoseAt)
s += `<tr><td class="label">${escHtml(t("report.docLastDose"))}</td><td>${formatDate(data.lastDoseAt)}</td></tr>`;
s += `</tbody></table>`;
} else {
s += `<p class="no-data">${escHtml(t("report.docNoDoses"))}</p>`;
}
// Refill history
if (data.refills.length > 0) {
s += `<h3>${escHtml(t("report.docRefillHistory"))}</h3>`;
s += `<ul>`;
for (const r of data.refills) {
let entry = `${formatDate(r.refillDate)}: +${r.packsAdded} ${escHtml(t("report.docPacks"))}, +${r.quantityAdded} ${escHtml(isTubePackageType(med.packageType) || isLiquidContainerPackageType(med.packageType) ? t(getTubeUnitKey(med)) : t("common.pills"))}`;
if (r.usedPrescription) entry += ` <em>${escHtml(t("report.docRefillPrescription"))}</em>`;
s += `<li>${entry}</li>`;
}
s += `</ul>`;
}
}
s += `</div>`;
sections.push(s);
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>${escHtml(t("report.docTitle"))}</title>
<style>
@media print {
body { margin: 0; padding: 1rem; }
.no-print { display: none !important; }
.med-section:last-child { margin-bottom: 0; padding-bottom: 0; }
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1e293b;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.5;
}
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
.subtitle { color: #64748b; margin-bottom: 1rem; }
.med-section { margin-bottom: 1.5rem; padding-bottom: 1rem; }
.med-section:last-child { }
h2 { font-size: 1.25rem; color: #0f172a; margin: 0; }
.generic-subtitle { margin: 0.1rem 0 0.5rem; font-size: 0.9rem; font-style: italic; color: #64748b; }
h2 + .med-overview { margin-top: 0.5rem; }
.med-overview { display: flex; gap: 1.25rem; align-items: flex-start; }
.med-overview-info { flex: 1; min-width: 0; }
.med-overview-info h3 { margin-top: 0; }
.med-img { width: 220px; height: 220px; border-radius: 8px; object-fit: cover; flex-shrink: 0; }
h3 { font-size: 0.9rem; font-weight: 600; color: #475569; text-transform: uppercase; letter-spacing: 0.05em; margin: 1rem 0 0.5rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 0.5rem; }
td { padding: 0.25rem 0.5rem; }
td.label { font-weight: 500; color: #475569; width: 40%; }
ul { margin: 0.25rem 0; padding-left: 1.5rem; }
li { margin: 0.25rem 0; }
.obsolete-badge { font-size: 0.75rem; background: #fef3c7; color: #92400e; padding: 0.125rem 0.5rem; border-radius: 4px; vertical-align: middle; }
.no-data { color: #94a3b8; font-style: italic; }
.print-hint { text-align: center; padding: 1rem; background: #f0f9ff; border-radius: 8px; color: #0369a1; margin-bottom: 1.5rem; }
</style>
</head>
<body>
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
<h1>${escHtml(t("report.docTitle"))}</h1>
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
${sections.join("\n")}
</body>
</html>`;
}
export default ReportModal;