Files
medassist-ng/frontend/src/pages/PlannerPage.tsx
T
Daniel Volz c78fc43083 feat(frontend): add intake journal and shared note flows (#648)
* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
2026-05-24 14:00:30 +02:00

308 lines
11 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.
/* biome-ignore-all lint/a11y/noLabelWithoutControl: planner uses custom DateTimeInput control wrappers */
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { DateTimeInput, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { PlannerRow } from "../types";
import { getMedDisplayName, isAmountBasedPackageType, isLiquidContainerPackageType, isTubePackageType } from "../types";
import { toInputValue } from "../utils/formatters";
// Date helpers
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function plusDaysIso(days: number): string {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
}
// Convert datetime-local value to ISO string
function toIsoString(value: string): string {
if (!value) return new Date().toISOString();
const date = new Date(value);
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
}
// Helper for user-specific localStorage keys
function userStorageKey(userId: number | undefined, key: string): string {
return userId ? `user_${userId}_${key}` : key;
}
export function PlannerPage() {
const { t } = useTranslation();
const { user, authFetch } = useAuth();
const { meds, settings, openMedDetail } = useAppContext();
// Local state for planner
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
const [plannerLoading, setPlannerLoading] = useState(false);
const [range, setRange] = useState<{ start: string; end: string }>({
start: toInputValue(todayIso()),
end: toInputValue(plusDaysIso(3)),
});
const [includeUntilStart, setIncludeUntilStart] = useState(false);
const [sendingPlannerEmail, setSendingPlannerEmail] = useState(false);
const [plannerEmailResult, setPlannerEmailResult] = useState<{ success: boolean; message: string } | null>(null);
// Load user-specific planner data when user changes
useEffect(() => {
if (typeof window !== "undefined" && user?.id) {
const savedRows = localStorage.getItem(userStorageKey(user.id, "plannerRows"));
const savedRange = localStorage.getItem(userStorageKey(user.id, "plannerRange"));
const savedIncludeUntilStart = localStorage.getItem(userStorageKey(user.id, "plannerIncludeUntilStart"));
if (savedRows) {
try {
setPlannerRows(JSON.parse(savedRows));
} catch {
setPlannerRows([]);
}
} else {
setPlannerRows([]);
}
if (savedRange) {
try {
setRange(JSON.parse(savedRange));
} catch {
/* keep default */
}
} else {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
}
if (savedIncludeUntilStart) {
setIncludeUntilStart(savedIncludeUntilStart === "true");
} else {
setIncludeUntilStart(false);
}
} else {
setPlannerRows([]);
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
setIncludeUntilStart(false);
}
}, [user?.id]);
async function runPlanner(e: React.FormEvent) {
e.preventDefault();
setPlannerLoading(true);
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart };
const rows = (await authFetch("/api/medications/usage", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((res) => res.json())
.catch(() => [])) as PlannerRow[];
setPlannerRows(rows);
setPlannerLoading(false);
// Save to user-specific localStorage
if (user?.id) {
localStorage.setItem(userStorageKey(user.id, "plannerRange"), JSON.stringify(range));
localStorage.setItem(userStorageKey(user.id, "plannerRows"), JSON.stringify(rows));
localStorage.setItem(userStorageKey(user.id, "plannerIncludeUntilStart"), String(includeUntilStart));
}
}
function resetRange() {
setRange({ start: toInputValue(todayIso()), end: toInputValue(plusDaysIso(3)) });
setIncludeUntilStart(false);
setPlannerRows([]);
if (user?.id) {
localStorage.removeItem(userStorageKey(user.id, "plannerRange"));
localStorage.removeItem(userStorageKey(user.id, "plannerRows"));
localStorage.removeItem(userStorageKey(user.id, "plannerIncludeUntilStart"));
}
}
const canSendNotification =
(settings.emailEnabled && settings.notificationEmail) || (settings.shoutrrrEnabled && settings.shoutrrrUrl);
const getDiscreteUnitLabel = (packageType: string | undefined, count: number): string => {
if (packageType === "inhaler") return count === 1 ? t("common.puff") : t("common.puffs");
if (packageType === "injection") return count === 1 ? t("common.injection") : t("common.injections");
return count === 1 ? t("common.pill") : t("common.pills");
};
const getUsageUnitLabel = (medicationId: number, count: number): string => {
const med = meds.find((m) => m.id === medicationId);
if (isLiquidContainerPackageType(med?.packageType)) {
return t("form.ml");
}
if (isTubePackageType(med?.packageType)) {
return med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
}
return getDiscreteUnitLabel(med?.packageType, count);
};
const getAvailableLabel = (medicationId: number, loosePills: number): string => {
const med = meds.find((m) => m.id === medicationId);
const roundedLoose = Math.round(loosePills * 10) / 10;
if (isLiquidContainerPackageType(med?.packageType)) {
return `${roundedLoose} ${t("form.ml")}`;
}
if (isTubePackageType(med?.packageType)) {
const unit = med?.medicationForm === "liquid" ? t("form.ml") : t("blisters.applications");
return `${roundedLoose} ${unit}`;
}
return `${roundedLoose} ${getDiscreteUnitLabel(med?.packageType, roundedLoose)}`;
};
async function sendPlannerNotification() {
if (!canSendNotification || plannerRows.length === 0) return;
setSendingPlannerEmail(true);
setPlannerEmailResult(null);
try {
const res = await authFetch("/api/planner/send-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: settings.notificationEmail,
from: range.start,
until: range.end,
rows: plannerRows,
}),
});
const data = await res.json();
if (res.ok) {
setPlannerEmailResult({ success: true, message: data.message || t("common.sent") });
} else {
setPlannerEmailResult({ success: false, message: data.error || t("common.sendFailed") });
}
} catch {
setPlannerEmailResult({ success: false, message: t("common.networkError") });
}
setSendingPlannerEmail(false);
}
return (
<section className="grid">
<article className="card" data-testid="planner-form-card">
<div className="card-head" data-testid="planner-page-header">
<h2>{t("planner.title")}</h2>
</div>
<form className="planner" onSubmit={runPlanner}>
<label>
{t("planner.from")}
<DateTimeInput
step="60"
value={range.start}
onChange={(e) => setRange({ ...range, start: e.target.value })}
/>
</label>
<label>
{t("planner.until")}
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
</label>
<div className="planner-checkbox-row">
<label className="planner-checkbox" data-testid="planner-include-until-start">
<input
type="checkbox"
checked={includeUntilStart}
onChange={(e) => setIncludeUntilStart(e.target.checked)}
/>
{t("planner.includeUntilStart")}
</label>
<span className="info-tooltip small" data-tooltip={t("planner.includeUntilStartTooltip")}>
</span>
</div>
<div className="planner-actions">
<button type="button" className="ghost" onClick={resetRange}>
{t("common.reset")}
</button>
<button type="submit" disabled={plannerLoading}>
{plannerLoading ? t("planner.calculating") : t("planner.calculate")}
</button>
</div>
</form>
{plannerRows.length > 0 && (
<>
<div className="table table-6">
<div className="table-head">
<span>{t("planner.table.medication")}</span>
<span>{t("planner.table.usage")}</span>
<span>{t("planner.table.blistersNeeded")}</span>
<span>{t("planner.table.prescriptionRefills")}</span>
<span>{t("planner.table.available")}</span>
<span>{t("table.status")}</span>
</div>
{plannerRows.map((row) => {
const med =
meds.find((m) => m.id === row.medicationId) ||
meds.find((m) => getMedDisplayName(m) === row.medicationName);
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
return (
<div
key={row.medicationId}
className="table-row clickable"
onClick={() => med && openMedDetail(med)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med) openMedDetail(med);
}
}}
>
<span data-label={t("planner.table.medication")} className="cell-with-avatar">
<MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />
{row.medicationName}
</span>
<span data-label={t("planner.table.usage")}>
<span>
<strong>{row.plannerUsage}</strong>&nbsp;
{getUsageUnitLabel(row.medicationId, row.plannerUsage)}
</span>
</span>
<span data-label={t("planner.table.blisters")}>
{isAmountBasedPackageType(row.packageType) ? "" : `${row.blistersNeeded} × ${row.blisterSize}`}
</span>
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? ""}</span>
<span data-label={t("planner.table.available")}>
{isAmountBasedPackageType(row.packageType) ? (
getAvailableLabel(row.medicationId, row.loosePills)
) : (
<>
{row.fullBlisters} {t("common.blisters")}
{row.loosePills > 0 &&
` + ${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`}
</>
)}
</span>
<span
data-label={t("table.status")}
className={row.enough ? "status-chip success" : "status-chip danger"}
>
{row.enough ? t("status.enough") : t("status.outOfStock")}
</span>
</div>
);
})}
</div>
{canSendNotification && (
<div className="planner-email-action">
<button
type="button"
className="ghost"
onClick={sendPlannerNotification}
disabled={sendingPlannerEmail}
>
{sendingPlannerEmail ? t("common.sending") : t("planner.sendNotification")}
</button>
{plannerEmailResult && (
<span className={plannerEmailResult.success ? "success-text" : "danger-text"}>
{plannerEmailResult.message}
</span>
)}
</div>
)}
</>
)}
</article>
</section>
);
}