refactor: decompose frontend state and medication dialog flows

This commit is contained in:
Daniel Volz
2026-03-27 06:50:19 +01:00
committed by GitHub
parent b58c4fe5bb
commit f46043970f
28 changed files with 2450 additions and 1613 deletions
+18 -72
View File
@@ -7,6 +7,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { ScheduleUsageTag } from "../features/schedule/components";
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
import { toggleDateInSet } from "../features/schedule/interactions";
import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage";
import { useEscapeKey } from "../hooks";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import {
@@ -20,9 +24,8 @@ import {
} from "../types";
import { getSystemLocale } from "../utils/formatters";
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
import { convertLiquidUsageToMl, getLiquidCountUnitLabel } from "../utils/intake-units";
import { convertLiquidUsageToMl } from "../utils/intake-units";
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
import { MedicationAvatar } from "./MedicationAvatar";
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
@@ -53,64 +56,17 @@ export function SharedSchedule() {
return convertLiquidUsageToMl(usage, unit);
};
const formatAmount = (value: number) => {
const rounded = Math.round(value * 100) / 100;
return String(rounded);
};
const formatLiquidUsageLabel = (usage: number, unit: IntakeUnit | null | undefined): string => {
const normalizedUsage = Number(usage);
if (!Number.isFinite(normalizedUsage) || normalizedUsage <= 0) {
return `0 ${t("form.packageAmountUnitMl")}`;
}
if (unit === "ml" || unit == null) {
return `${formatAmount(normalizedUsage)} ${t("form.packageAmountUnitMl")}`;
}
const mlTotal = convertLiquidUsageToMl(normalizedUsage, unit);
return `${formatAmount(normalizedUsage)} ${getLiquidCountUnitLabel(unit, normalizedUsage, t)} ${formatAmount(mlTotal)} ${t("form.packageAmountUnitMl")}`;
};
const formatDoseUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
usage: number,
intakeUnit?: IntakeUnit | null
) => {
if (isLiquidContainerMed(med)) {
return formatLiquidUsageLabel(usage, intakeUnit);
}
return `${usage} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
};
) => formatScheduleDoseUsageLabel(med, usage, t, intakeUnit);
const formatTotalUsageLabel = (
med: SharedScheduleData["medications"][number] | undefined,
total: number,
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
) => {
if (isLiquidContainerMed(med)) {
if (doses && doses.length > 0) {
const normalizedDoses = doses.filter((dose) => Number.isFinite(Number(dose.usage)) && Number(dose.usage) > 0);
if (normalizedDoses.length > 0) {
const allUnits = new Set(normalizedDoses.map((dose) => dose.intakeUnit ?? "ml"));
if (allUnits.size === 1) {
const onlyUnit = normalizedDoses[0]?.intakeUnit ?? "ml";
const totalUsageInUnit = normalizedDoses.reduce((sum, dose) => sum + Number(dose.usage), 0);
return formatLiquidUsageLabel(totalUsageInUnit, onlyUnit);
}
const totalMl = normalizedDoses.reduce(
(sum, dose) => sum + convertLiquidUsageToMl(Number(dose.usage), dose.intakeUnit ?? "ml"),
0
);
return `${formatAmount(totalMl)} ${t("form.packageAmountUnitMl")}`;
}
}
return `${formatAmount(total)} ${t("form.packageAmountUnitMl")}`;
}
return t("common.pillsTotal", { count: total });
};
) => formatScheduleTotalUsageLabel(med, total, t, doses);
// Theme preference: light, dark, or system
type ThemePreference = "light" | "dark" | "system";
@@ -172,7 +128,7 @@ export function SharedSchedule() {
// Load collapsed/expanded state from localStorage
useEffect(() => {
if (token && typeof window !== "undefined") {
const { collapsed, expanded } = loadCollapsedDaysFromStorage(
const { collapsed, expanded } = loadScheduleCollapseState(
`share_${token}_collapsedDays`,
`share_${token}_expandedDays`
);
@@ -185,24 +141,14 @@ export function SharedSchedule() {
function toggleDayCollapse(dateStr: string, isAutoCollapsed: boolean) {
if (isAutoCollapsed) {
setManuallyExpandedDays((prev) => {
const next = new Set(prev);
if (next.has(dateStr)) {
next.delete(dateStr);
} else {
next.add(dateStr);
}
if (token) localStorage.setItem(`share_${token}_expandedDays`, JSON.stringify([...next]));
const next = toggleDateInSet(prev, dateStr);
if (token) saveCollapsedDaySet(`share_${token}_expandedDays`, next);
return next;
});
} else {
setManuallyCollapsedDays((prev) => {
const next = new Set(prev);
if (next.has(dateStr)) {
next.delete(dateStr);
} else {
next.add(dateStr);
}
if (token) localStorage.setItem(`share_${token}_collapsedDays`, JSON.stringify([...next]));
const next = toggleDateInSet(prev, dateStr);
if (token) saveCollapsedDaySet(`share_${token}_collapsedDays`, next);
return next;
});
}
@@ -977,9 +923,9 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">
<ScheduleUsageTag>
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
</ScheduleUsageTag>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
@@ -1192,9 +1138,9 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">
<ScheduleUsageTag>
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
</ScheduleUsageTag>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
@@ -1394,9 +1340,9 @@ export function SharedSchedule() {
</div>
</div>
<div className="tag-row">
<span className="tag subtle">
<ScheduleUsageTag>
{formatTotalUsageLabel(med, item.total, item.doses)}
</span>
</ScheduleUsageTag>
{isLowStock && <span className="tag warning">{t("status.lowStock")}</span>}
</div>
</div>
@@ -0,0 +1,265 @@
import { type Coverage, getMedDisplayName, type Medication, type StockThresholds } from "../../types";
import { getStockStatus } from "../../utils/schedule";
type ReminderData = {
status: { className: string; text: string };
lowStockMeds: Array<{ name: string; daysLeft: number; isCritical: boolean }>;
lastStockSent: { medNames: string | null; date: string } | null;
lastIntakeSent: { medName: string | null; takenBy: string | null; date: string } | null;
};
type PrescriptionLowMed = {
id: number;
name: string;
remainingRefills: number;
threshold: number;
};
type DashboardReminderSectionProps = {
t: (key: string, options?: Record<string, unknown>) => string;
remindersLoading: boolean;
anyRemindersEnabled: boolean;
stockRemindersEnabled: boolean;
intakeRemindersEnabled: boolean;
prescriptionRemindersEnabled: boolean;
reminderData: ReminderData;
prescriptionLowMeds: PrescriptionLowMed[];
prescriptionStatus: { text: string; className: string } | null;
meds: Medication[];
coverage: { all: Coverage[] };
stockThresholds: StockThresholds;
sendingReminder: boolean;
reminderResult: { success: boolean; message: string } | null;
onSendManualReminder: () => void;
onOpenMedicationDetail: (med: Medication) => void;
};
function NotificationBellIcon() {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ display: "block" }}
>
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
);
}
export function DashboardReminderSection({
t,
remindersLoading,
anyRemindersEnabled,
stockRemindersEnabled,
intakeRemindersEnabled,
prescriptionRemindersEnabled,
reminderData,
prescriptionLowMeds,
prescriptionStatus,
meds,
coverage,
stockThresholds,
sendingReminder,
reminderResult,
onSendManualReminder,
onOpenMedicationDetail,
}: DashboardReminderSectionProps) {
const getStatusTextClass = (statusClassName: string | undefined): string => {
if (statusClassName === "danger") return "danger-text";
if (statusClassName === "warning") return "warning-text";
return "";
};
if (remindersLoading) {
return (
<section className="reminder-status-bar reminder-status-skeleton" aria-busy="true">
<div className="reminder-status-header">
<span className="reminder-status-icon">
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
</div>
<div className="reminder-status-details reminder-status-skeleton-lines">
<span className="skeleton-line skeleton-line-long" />
<span className="skeleton-line skeleton-line-medium" />
<span className="skeleton-line skeleton-line-short" />
</div>
</section>
);
}
if (!anyRemindersEnabled) {
return null;
}
return (
<section className="reminder-status-bar">
<div className="reminder-status-header">
<span className="reminder-status-icon">
<NotificationBellIcon />
</span>
<span className="reminder-status-title">{t("dashboard.reminders.active")}</span>
{stockRemindersEnabled && (
<span className={`status-chip small ${reminderData.status.className}`}>{reminderData.status.text}</span>
)}
{prescriptionStatus && (
<span className={`status-chip small ${prescriptionStatus.className}`}>{prescriptionStatus.text}</span>
)}
</div>
{(reminderData.lowStockMeds.length > 0 ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0) ||
(stockRemindersEnabled && reminderData.lastStockSent) ||
(intakeRemindersEnabled && reminderData.lastIntakeSent)) && (
<div className="reminder-status-details">
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{reminderData.lowStockMeds.map((med, idx) => {
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
const cov = coverage.all.find((c) => c.name === med.name);
const status = cov
? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds, medication?.packageType)
: null;
const textClass = getStatusTextClass(status?.className);
return (
<span key={med.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && medication) {
onOpenMedicationDetail(medication);
}
}}
>
{med.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
</span>
</span>
);
})}
</span>
</div>
)}
{prescriptionRemindersEnabled && prescriptionLowMeds.length > 0 && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.needsPrescriptionRefill")}:</span>
<span className="reminder-status-value">
{prescriptionLowMeds.map((med, idx) => {
const medication = meds.find((m) => m.id === med.id);
const textClass = med.remainingRefills <= 0 ? "danger-text" : "warning-text";
return (
<span key={med.id}>
{idx > 0 && ", "}
<span className={`reminder-days-left ${textClass}`}>
{t("prescription.remainingRefills")}: {med.remainingRefills} · {t("dashboard.reminders.usedBy")}
:{" "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => medication && onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && medication) {
onOpenMedicationDetail(medication);
}
}}
>
{med.name}
</span>
</span>
</span>
);
})}
</span>
</div>
)}
{stockRemindersEnabled && reminderData.lastStockSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastStockSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastStockSent.medNames &&
(() => {
const names = reminderData.lastStockSent?.medNames?.split(", ") ?? [];
return names.map((name, idx) => {
const medication = meds.find((m) => getMedDisplayName(m) === name);
return (
<span key={name}>
{idx > 0 && ", "}
{medication ? (
<span
className="med-link clickable"
onClick={() => onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpenMedicationDetail(medication);
}}
>
{name}
</span>
) : (
<span className="reminder-med-name">{name}</span>
)}
</span>
);
});
})()}
<span className="reminder-date"> {reminderData.lastStockSent.date}</span>
</span>
</div>
)}
{intakeRemindersEnabled && reminderData.lastIntakeSent && (
<div className="reminder-status-row">
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
<span className="reminder-status-value">
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find((m) => getMedDisplayName(m) === reminderData.lastIntakeSent?.medName);
return medication ? (
<span
className="med-link clickable"
onClick={() => onOpenMedicationDetail(medication)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onOpenMedicationDetail(medication);
}}
>
{reminderData.lastIntakeSent?.medName}
</span>
) : (
<span className="reminder-med-name">{reminderData.lastIntakeSent?.medName}</span>
);
})()}
{reminderData.lastIntakeSent.takenBy && (
<span className="reminder-taken-by"> ({reminderData.lastIntakeSent.takenBy})</span>
)}
<span className="reminder-date"> {reminderData.lastIntakeSent.date}</span>
</span>
</div>
)}
</div>
)}
{((stockRemindersEnabled && reminderData.lowStockMeds.length > 0) ||
(prescriptionRemindersEnabled && prescriptionLowMeds.length > 0)) && (
<div className="reminder-send-row">
<button type="button" className="ghost" onClick={onSendManualReminder} disabled={sendingReminder}>
{sendingReminder ? t("common.sending") : t("dashboard.reorder.sendReminder")}
</button>
{reminderResult && (
<span className={`reminder-send-result ${reminderResult.success ? "success" : "error"}`}>
{reminderResult.message}
</span>
)}
</div>
)}
</section>
);
}
@@ -0,0 +1,96 @@
import type { Coverage, Medication, StockThresholds } from "../../types";
import { getMedDisplayName } from "../../types";
import { getStockStatus } from "../../utils/schedule";
type DashboardStatusSectionProps = {
t: (key: string, options?: Record<string, unknown>) => string;
show: boolean;
meds: Medication[];
coverage: { all: Coverage[] };
stockThresholds: StockThresholds;
onOpenMedicationDetail: (med: Medication) => void;
};
export function DashboardStatusSection({
t,
show,
meds,
coverage,
stockThresholds,
onOpenMedicationDetail,
}: DashboardStatusSectionProps) {
const getStatusTextClass = (statusClassName: string): string => {
if (statusClassName === "danger") return "danger-text";
if (statusClassName === "warning") return "warning-text";
return "";
};
if (!show) {
return null;
}
return (
<section className="grid">
<article className="card">
<div className="card-head">
<h2>{t("dashboard.reorder.title")}</h2>
</div>
{(() => {
if (meds.length === 0) {
return <p className="muted">{t("dashboard.reorder.noMeds")}</p>;
}
const lowStockMap = new Map<string, Coverage>();
for (const c of coverage.all) {
if (c.daysLeft === null && c.medsLeft > 0) continue;
const med = meds.find((m) => getMedDisplayName(m) === c.name);
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
if (status.className === "danger" || status.className === "warning") {
const existing = lowStockMap.get(c.name);
if (!existing || (c.daysLeft ?? 0) < (existing.daysLeft ?? 0)) {
lowStockMap.set(c.name, c);
}
}
}
const lowStockMeds = Array.from(lowStockMap.values());
const lowStockCount = lowStockMeds.length;
if (lowStockCount === 0) {
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
}
return (
<p>
{t("dashboard.reorder.lowWarningPrefix")}{" "}
{lowStockMeds.map((c, idx) => {
const med = meds.find((m) => getMedDisplayName(m) === c.name);
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds, med?.packageType);
const textClass = getStatusTextClass(status.className);
return (
<span key={c.name}>
{idx > 0 && ", "}
<span
className={`med-link clickable ${textClass}`}
onClick={() => med && onOpenMedicationDetail(med)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && med) {
onOpenMedicationDetail(med);
}
}}
>
{c.name}
</span>
<span className={`reminder-days-left ${textClass}`}>
{" "}
({t("dashboard.reminders.daysLeft", { count: c.daysLeft ?? 0, days: c.daysLeft ?? 0 })})
</span>
</span>
);
})}{" "}
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
</p>
);
})()}
</article>
</section>
);
}
+3
View File
@@ -18,6 +18,9 @@ export type { MedicationEnrichmentViewModel } from "./MedicationEnrichmentSectio
export { MedicationEnrichmentSection } from "./MedicationEnrichmentSection";
export type { MobileEditModalProps } from "./MobileEditModal";
export { MobileEditModal } from "./MobileEditModal";
export { MedicationDialogs } from "./medications/MedicationDialogs";
export { MedicationEditCoordinator } from "./medications/MedicationEditCoordinator";
export { MedicationListSection } from "./medications/MedicationListSection";
export { PasswordInput } from "./PasswordInput";
export { default as ProfileModal } from "./ProfileModal";
export { default as ReportModal } from "./ReportModal";
@@ -0,0 +1,120 @@
import type React from "react";
import type { Medication } from "../../types";
import { ConfirmModal } from "../ConfirmModal";
import { Lightbox } from "../Lightbox";
import ReportModal from "../ReportModal";
type MedicationDialogsProps = {
mobileEditModal: React.ReactNode;
showUnsavedConfirm: boolean;
unsavedCancelLabel: string;
unsavedConfirmLabel: string;
unsavedMessage: string;
unsavedTitle: string;
onConfirmClose: () => void;
onCancelClose: () => void;
showObsoleteConfirm: boolean;
obsoleteCandidate: Medication | null;
obsoleteTitle: string;
obsoleteMessage: string;
obsoleteConfirmLabel: string;
obsoleteCancelLabel: string;
onConfirmMarkObsolete: () => void;
onCancelMarkObsolete: () => void;
showDeleteConfirm: boolean;
deleteCandidate: Medication | null;
deleteTitle: string;
deleteMessage: string;
deleteConfirmLabel: string;
deleteCancelLabel: string;
onConfirmDelete: () => void;
onCancelDelete: () => void;
showEditModal: boolean;
lightboxImage: { src: string; alt: string } | null;
onCloseLightbox: () => void;
showReportModal: boolean;
onCloseReportModal: () => void;
medications: Medication[];
};
export function MedicationDialogs({
mobileEditModal,
showUnsavedConfirm,
unsavedCancelLabel,
unsavedConfirmLabel,
unsavedMessage,
unsavedTitle,
onConfirmClose,
onCancelClose,
showObsoleteConfirm,
obsoleteCandidate,
obsoleteTitle,
obsoleteMessage,
obsoleteConfirmLabel,
obsoleteCancelLabel,
onConfirmMarkObsolete,
onCancelMarkObsolete,
showDeleteConfirm,
deleteCandidate,
deleteTitle,
deleteMessage,
deleteConfirmLabel,
deleteCancelLabel,
onConfirmDelete,
onCancelDelete,
showEditModal,
lightboxImage,
onCloseLightbox,
showReportModal,
onCloseReportModal,
medications,
}: MedicationDialogsProps) {
return (
<>
{mobileEditModal}
{showUnsavedConfirm && (
<ConfirmModal
title={unsavedTitle}
message={unsavedMessage}
confirmLabel={unsavedConfirmLabel}
cancelLabel={unsavedCancelLabel}
onConfirm={onConfirmClose}
onCancel={onCancelClose}
confirmVariant="danger"
overlayClassName={showEditModal ? "nested-confirm" : undefined}
/>
)}
{showObsoleteConfirm && obsoleteCandidate && (
<ConfirmModal
title={obsoleteTitle}
message={obsoleteMessage}
confirmLabel={obsoleteConfirmLabel}
cancelLabel={obsoleteCancelLabel}
onConfirm={onConfirmMarkObsolete}
onCancel={onCancelMarkObsolete}
confirmVariant="warning"
overlayClassName={showEditModal ? "nested-confirm" : undefined}
/>
)}
{showDeleteConfirm && deleteCandidate && (
<ConfirmModal
title={deleteTitle}
message={deleteMessage}
confirmLabel={deleteConfirmLabel}
cancelLabel={deleteCancelLabel}
onConfirm={onConfirmDelete}
onCancel={onCancelDelete}
confirmVariant="danger"
overlayClassName={showEditModal ? "nested-confirm" : undefined}
/>
)}
{lightboxImage && <Lightbox src={lightboxImage.src} alt={lightboxImage.alt} onClose={onCloseLightbox} />}
<ReportModal isOpen={showReportModal} onClose={onCloseReportModal} medications={medications} />
</>
);
}
@@ -0,0 +1,55 @@
import type React from "react";
import { useTranslation } from "react-i18next";
type MedicationEditCoordinatorProps = {
viewMode: "grid" | "form";
editingId: number | null;
readOnlyView: boolean;
selectedMedicationName?: string;
onBack: () => void;
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
children: React.ReactNode;
};
export function MedicationEditCoordinator({
viewMode,
editingId,
readOnlyView,
selectedMedicationName,
onBack,
onSubmit,
children,
}: MedicationEditCoordinatorProps) {
const { t } = useTranslation();
return (
<aside className={`edit-sidebar desktop-only${viewMode === "form" ? " open" : ""}`}>
<article className="card form">
<div className="card-head">
<div className="edit-header">
<button type="button" className="ghost small btn-nav" onClick={onBack}>
{"<-"} {t("common.back")}
</button>
{editingId ? (
<h2>
{readOnlyView ? t("form.viewEntry") : t("form.editEntry")}: {selectedMedicationName}
</h2>
) : (
<h2>{t("form.newEntry")}</h2>
)}
</div>
</div>
<form
className="form-grid"
onSubmit={onSubmit}
autoComplete="off"
spellCheck={false}
autoCorrect="off"
autoCapitalize="off"
>
{children}
</form>
</article>
</aside>
);
}
@@ -0,0 +1,264 @@
import { Archive, Bell, Eye, Pencil, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { Medication } from "../../types";
import { getMedDisplayName, getMedTotal, getStockDisplayCapacity, isAmountBasedPackageType } from "../../types";
import { formatDate, formatDateTime } from "../../utils/formatters";
import { getIntakeFrequencyText, getMedicationIntakes } from "../../utils/intake-schedule";
import { MedicationAvatar } from "../MedicationAvatar";
type MedicationListSectionProps = {
orderedMeds: Medication[];
obsoleteMeds: Medication[];
editingId: number | null;
showObsolete: boolean;
coverageByMed: Record<string, { medsLeft: number }>;
onNewEntry: () => void;
onOpenReport: () => void;
onEdit: (med: Medication) => void;
onView: (med: Medication) => void;
onMarkObsolete: (med: Medication) => void;
onDelete: (med: Medication) => void;
onReactivate: (medId: number) => void;
onToggleObsolete: () => void;
onImagePreview: (med: Medication) => void;
getMedicationPackageTypeLabel: (med: Medication) => string;
getMedicationStockSuffix: (med: Medication) => string;
getMedicationUsageUnitLabel: (med: Medication, usage: number) => string;
};
export function MedicationListSection({
orderedMeds,
obsoleteMeds,
editingId,
showObsolete,
coverageByMed,
onNewEntry,
onOpenReport,
onEdit,
onView,
onMarkObsolete,
onDelete,
onReactivate,
onToggleObsolete,
onImagePreview,
getMedicationPackageTypeLabel,
getMedicationStockSuffix,
getMedicationUsageUnitLabel,
}: MedicationListSectionProps) {
const { t } = useTranslation();
const renderImageAvatar = (med: Medication) => (
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() => med.imageUrl && onImagePreview(med)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && med.imageUrl) {
onImagePreview(med);
}
}}
>
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
</span>
);
return (
<article className="card">
<div className="card-head">
<h2>{t("medications.list.title")}</h2>
<div className="card-head-actions">
<button type="button" className="btn primary small" onClick={onNewEntry}>
+ {t("form.newEntry")}
</button>
<button type="button" className="btn ghost small" onClick={onOpenReport}>
{t("report.button")}
</button>
</div>
</div>
<div className="med-groups">
<div className="med-group med-group-active">
<div className="med-grid">
{orderedMeds.map((med) => {
const displayName = getMedDisplayName(med);
const stockDisplayCapacity = getStockDisplayCapacity(med);
const currentStock = coverageByMed[displayName]
? Math.round(coverageByMed[displayName].medsLeft)
: getMedTotal(med);
return (
<div key={med.id} className={`med-row${editingId === med.id ? " editing" : ""}`}>
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
{renderImageAvatar(med)}
<div className="med-name-block">
<div className="med-name">{displayName}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
{editingId !== med.id && (
<button
className="info icon-only tooltip-trigger"
onClick={() => onEdit(med)}
aria-label={t("common.edit")}
data-tooltip={t("common.edit")}
>
<Pencil size={18} aria-hidden="true" />
</button>
)}
<button
type="button"
className="btn-obsolete"
onClick={() => onMarkObsolete(med)}
aria-label={t("medications.list.markObsolete")}
>
<Archive size={16} aria-hidden="true" />
<span>{t("medications.list.markObsolete")}</span>
</button>
<button
type="button"
className="danger icon-only tooltip-trigger"
onClick={() => onDelete(med)}
aria-label={t("common.delete")}
data-tooltip={t("common.delete")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
</div>
<div className="med-details">
<span>
{t("medications.details.type")}: <strong>{getMedicationPackageTypeLabel(med)}</strong>
</span>
{!isAmountBasedPackageType(med.packageType) ? (
<>
<span>
{t("medications.details.packs")}: <strong>{med.packCount}</strong>
</span>
<span>
{t("medications.details.blisters")}: <strong>{med.blistersPerPack}</strong>
</span>
<span>
{t("medications.details.pillsPerBlister")}: <strong>{med.pillsPerBlister}</strong>
</span>
<span>
{t("medications.details.loose")}: <strong>{med.looseTablets}</strong>
</span>
</>
) : (
<span>
{t("medications.details.totalCapacity")}:{" "}
<strong>{med.totalPills ?? med.looseTablets}</strong>
</span>
)}
</div>
{med.prescriptionEnabled && (
<div className="med-total">
{t("prescription.remainingRefills")}: <strong>{med.prescriptionRemainingRefills ?? 0}</strong>
</div>
)}
<div className="med-total">
{t("medications.details.stock")}: {currentStock} / {stockDisplayCapacity}
{getMedicationStockSuffix(med)}
{currentStock > stockDisplayCapacity ? (
<span
className="info-tooltip tooltip-align-left warning-text"
data-tooltip={t("tooltips.stockExceedsCapacity")}
>
{" "}
</span>
) : null}
</div>
</div>
</div>
<div className="blister-list">
{getMedicationIntakes(med).map((intake) => (
<div
key={`${med.id}-${intake.start}-${intake.usage}-${intake.takenBy ?? "none"}`}
className="blister-row-simple"
>
{intake.usage} {getMedicationUsageUnitLabel(med, intake.usage)} ·
{getIntakeFrequencyText(intake, t)} · {t("form.blisters.from")} {formatDateTime(intake.start)}
{intake.takenBy && <span className="blister-taken-by"> · {intake.takenBy}</span>}
{intake.intakeRemindersEnabled && (
<span className="blister-reminder-icon" title={t("form.blisters.remindTooltip")}>
{" "}
<Bell size={12} aria-hidden="true" />
</span>
)}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
{obsoleteMeds.length > 0 && (
<div className="med-group med-group-obsolete">
<button
type="button"
className="med-group-head med-group-head-toggle"
onClick={onToggleObsolete}
aria-expanded={showObsolete}
>
<h3 className="med-group-title">
{showObsolete ? "▼" : "▶"} {t("medications.list.obsoleteTitle", { count: obsoleteMeds.length })}
</h3>
</button>
{showObsolete && (
<div className="med-grid med-grid-obsolete">
{obsoleteMeds.map((med) => (
<div key={med.id} className="med-row obsolete-row">
<div className="med-header">
<div className="med-info">
<div className="med-name-row">
{renderImageAvatar(med)}
<div className="med-name-block">
<div className="med-name">{getMedDisplayName(med)}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
<button
className="info icon-only tooltip-trigger"
onClick={() => onView(med)}
aria-label={t("common.view")}
data-tooltip={t("common.view")}
>
<Eye size={18} aria-hidden="true" />
</button>
<button
className="danger icon-only tooltip-trigger"
onClick={() => onDelete(med)}
aria-label={t("common.delete")}
data-tooltip={t("common.delete")}
>
<Trash2 size={18} aria-hidden="true" />
</button>
<button className="success" onClick={() => onReactivate(med.id)}>
{t("medications.list.reactivate")}
</button>
</div>
<div className="med-details">
{med.medicationStartDate && (
<span style={{ gridColumn: "1 / -1" }}>
{t("medications.list.started")}: <strong>{formatDate(med.medicationStartDate)}</strong>
</span>
)}
<span style={{ gridColumn: "1 / -1" }}>
{t("medications.list.obsoleteSince")}: <strong>{formatDate(med.obsoleteAt)}</strong>
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</article>
);
}