refactor: decompose frontend state and medication dialog flows
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user