feat: add shared overview and harden frontend session state (#407)
This commit is contained in:
@@ -16,6 +16,7 @@ import { Lightbox, MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks";
|
||||
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
|
||||
import {
|
||||
allowsPillFormSelection,
|
||||
getMedDisplayName,
|
||||
getMedTotal,
|
||||
getPackageSize,
|
||||
@@ -245,6 +246,14 @@ export function MedDetailModal({
|
||||
const closeLabel = t("common.close");
|
||||
const decrementLabel = t("editStock.decreaseValue");
|
||||
const incrementLabel = t("editStock.increaseValue");
|
||||
const showPillWeightDetails = allowsPillFormSelection(selectedMed.packageType) && !!selectedMed.pillWeightMg;
|
||||
const pillWeightMg = showPillWeightDetails ? (selectedMed.pillWeightMg ?? 0) : 0;
|
||||
const isTubeRefillPackage = isTubePackageType(selectedMed.packageType);
|
||||
const isLiquidRefillPackage =
|
||||
isLiquidContainerPackageType(selectedMed.packageType) || selectedMed.medicationForm === "liquid";
|
||||
const isCountBasedAmountRefillPackage = isLiquidRefillPackage || isTubeRefillPackage;
|
||||
const liquidRefillAmountPerBottle = Math.max(1, Math.round(Number.isFinite(amountPerPackage) ? amountPerPackage : 1));
|
||||
const amountRefillPackageCount = Math.max(0, Math.round(refillLoose / liquidRefillAmountPerBottle));
|
||||
const getScheduleUsageLabel = (usage: number, intakeUnit?: "ml" | "tsp" | "tbsp" | null) => {
|
||||
if (isLiquidContainerPackageType(selectedMed.packageType)) {
|
||||
if (intakeUnit === "tsp") {
|
||||
@@ -934,7 +943,7 @@ export function MedDetailModal({
|
||||
<span className="med-detail-value">{(selectedMed.totalPills ?? packageSize) || "—"}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMed.pillWeightMg && (
|
||||
{showPillWeightDetails && (
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("modal.pillWeight")}</span>
|
||||
<span className="med-detail-value">
|
||||
@@ -984,8 +993,7 @@ export function MedDetailModal({
|
||||
>
|
||||
<span className="med-schedule-usage">
|
||||
{getScheduleUsageLabel(totalUsage, intake.intakeUnit)}
|
||||
{selectedMed.pillWeightMg &&
|
||||
` (${totalUsage * selectedMed.pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
{showPillWeightDetails && ` (${totalUsage * pillWeightMg} ${selectedMed.doseUnit ?? "mg"})`}
|
||||
</span>
|
||||
<span className="med-schedule-freq">
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}
|
||||
@@ -1236,6 +1244,23 @@ export function MedDetailModal({
|
||||
})}
|
||||
</label>
|
||||
</>
|
||||
) : isCountBasedAmountRefillPackage ? (
|
||||
<label>
|
||||
{isTubeRefillPackage ? t("form.tubes") : t("form.bottles")}
|
||||
{renderRefillStepperInput({
|
||||
value: amountRefillPackageCount,
|
||||
min: 0,
|
||||
max: Number.MAX_SAFE_INTEGER,
|
||||
onChange: (nextPackages) => {
|
||||
onRefillPacksChange(nextPackages);
|
||||
onRefillLooseChange(nextPackages * liquidRefillAmountPerBottle);
|
||||
},
|
||||
})}
|
||||
<p className="edit-stock-cap-info" style={{ marginTop: "0.35rem" }}>
|
||||
{isTubeRefillPackage ? t("form.packageAmountPerTube") : t("form.packageAmountPerBottle")}:{" "}
|
||||
{formatNumber(liquidRefillAmountPerBottle)} {amountUnitLabel}
|
||||
</p>
|
||||
</label>
|
||||
) : (
|
||||
<label>
|
||||
{t("refill.pillsToAdd")}
|
||||
@@ -1286,7 +1311,9 @@ export function MedDetailModal({
|
||||
onClick={() => onSubmitRefill(selectedMed.id, usePrescriptionRefill)}
|
||||
disabled={
|
||||
(isAmountBasedPackageType(selectedMed.packageType)
|
||||
? refillLoose < 1
|
||||
? isCountBasedAmountRefillPackage
|
||||
? amountRefillPackageCount < 1
|
||||
: refillLoose < 1
|
||||
: cappedRefillPacks < 1 && refillLoose < 1) ||
|
||||
exceedsPrescriptionPackLimit ||
|
||||
refillSaving
|
||||
@@ -1297,7 +1324,9 @@ export function MedDetailModal({
|
||||
{(() => {
|
||||
const totalRefill = !isAmountBasedPackageType(selectedMed.packageType)
|
||||
? cappedRefillPacks * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + refillLoose
|
||||
: refillLoose;
|
||||
: isCountBasedAmountRefillPackage
|
||||
? amountRefillPackageCount * liquidRefillAmountPerBottle
|
||||
: refillLoose;
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill}
|
||||
|
||||
@@ -435,7 +435,7 @@ export function MobileEditModal({
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
className="select-field package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value as FormState["packageType"])}
|
||||
>
|
||||
@@ -458,6 +458,7 @@ export function MobileEditModal({
|
||||
<label className="full">
|
||||
{t("form.pillForm")}
|
||||
<select
|
||||
className="select-field"
|
||||
value={form.pillForm}
|
||||
onChange={(e) => onHandleValueChange("pillForm", e.target.value as FormState["pillForm"])}
|
||||
>
|
||||
@@ -469,7 +470,11 @@ export function MobileEditModal({
|
||||
{isTubePackageType(form.packageType) && (
|
||||
<label className="full">
|
||||
{t("form.medicationForm")}
|
||||
<select value={"topical"} onChange={() => onHandleValueChange("medicationForm", "topical")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"topical"}
|
||||
onChange={() => onHandleValueChange("medicationForm", "topical")}
|
||||
>
|
||||
<option value="topical">{t("form.medicationFormTopical")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -477,7 +482,11 @@ export function MobileEditModal({
|
||||
{isLiquidContainerPackageType(form.packageType) && (
|
||||
<label className="full">
|
||||
{t("form.medicationForm")}
|
||||
<select value={"liquid"} onChange={() => onHandleValueChange("medicationForm", "liquid")}>
|
||||
<select
|
||||
className="select-field"
|
||||
value={"liquid"}
|
||||
onChange={() => onHandleValueChange("medicationForm", "liquid")}
|
||||
>
|
||||
<option value="liquid">{t("form.medicationFormLiquid")}</option>
|
||||
</select>
|
||||
</label>
|
||||
@@ -630,7 +639,7 @@ export function MobileEditModal({
|
||||
<select
|
||||
value="g"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitG")}
|
||||
>
|
||||
<option value="g">{t("form.packageAmountUnitG")}</option>
|
||||
@@ -675,7 +684,7 @@ export function MobileEditModal({
|
||||
<select
|
||||
value="ml"
|
||||
disabled
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
aria-label={t("form.packageAmountUnitMl")}
|
||||
>
|
||||
<option value="ml">{t("form.packageAmountUnitMl")}</option>
|
||||
@@ -743,7 +752,7 @@ export function MobileEditModal({
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
className="select-field dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
@@ -849,6 +858,7 @@ export function MobileEditModal({
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.intakeUnit")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.intakeUnit}
|
||||
onChange={(e) =>
|
||||
onSetIntakeValue(idx, "intakeUnit", e.target.value as "ml" | "tsp" | "tbsp")
|
||||
@@ -864,6 +874,7 @@ export function MobileEditModal({
|
||||
<label className="compact full-row taken-by-field">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select
|
||||
className="select-field"
|
||||
value={intake.takenBy}
|
||||
onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}
|
||||
>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { Check, Copy, Link2, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
@@ -40,8 +41,49 @@ export function ShareDialog({
|
||||
onCopyShareLink,
|
||||
}: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [overviewCopied, setOverviewCopied] = useState(false);
|
||||
const closeLabel = t("common.close");
|
||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||
const overviewCopyLabel = overviewCopied ? t("share.copied") : t("share.copyOverviewLink");
|
||||
const overviewLink = shareLink ? `${shareLink}/overview` : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shareLink) {
|
||||
setOverviewCopied(false);
|
||||
}
|
||||
}, [shareLink]);
|
||||
|
||||
const copyOverviewLink = async () => {
|
||||
if (!overviewLink) return;
|
||||
|
||||
const markCopied = () => {
|
||||
setOverviewCopied(true);
|
||||
setTimeout(() => setOverviewCopied(false), 2000);
|
||||
};
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(overviewLink);
|
||||
markCopied();
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to textarea-based copy.
|
||||
}
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = overviewLink;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
markCopied();
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
};
|
||||
|
||||
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
||||
|
||||
@@ -91,6 +133,7 @@ export function ShareDialog({
|
||||
return (
|
||||
<div className="share-dialog-result">
|
||||
<p className="share-success">{t("share.linkGenerated")}</p>
|
||||
<p className="share-link-label">{t("share.scheduleLink")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
@@ -109,13 +152,34 @@ export function ShareDialog({
|
||||
{shareCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="share-link-label">{t("share.overviewLink")}</p>
|
||||
<div className="share-link-box">
|
||||
<input
|
||||
type="text"
|
||||
value={overviewLink ?? ""}
|
||||
readOnly
|
||||
className="share-link-input"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-copy icon-only tooltip-trigger"
|
||||
onClick={copyOverviewLink}
|
||||
aria-label={overviewCopyLabel}
|
||||
data-tooltip={overviewCopyLabel}
|
||||
>
|
||||
{overviewCopied ? <Check size={18} aria-hidden="true" /> : <Copy size={18} aria-hidden="true" />}
|
||||
</button>
|
||||
</div>
|
||||
{shareCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
{overviewCopied && <span className="share-copied-hint">{t("share.copied")}</span>}
|
||||
<div className="share-dialog-footer">
|
||||
<button
|
||||
className="ghost"
|
||||
onClick={() => {
|
||||
onShareLinkChange(null);
|
||||
onShareCopiedChange(false);
|
||||
setOverviewCopied(false);
|
||||
}}
|
||||
>
|
||||
{t("share.generateAnother")}
|
||||
@@ -131,6 +195,7 @@ export function ShareDialog({
|
||||
<label htmlFor="share-person-select">{t("share.selectPerson")}</label>
|
||||
<select
|
||||
id="share-person-select"
|
||||
className="select-field"
|
||||
value={shareSelectedPerson}
|
||||
onChange={(e) => onShareSelectedPersonChange(e.target.value)}
|
||||
>
|
||||
@@ -146,6 +211,7 @@ export function ShareDialog({
|
||||
<label htmlFor="share-period-select">{t("share.selectPeriod")}</label>
|
||||
<select
|
||||
id="share-period-select"
|
||||
className="select-field"
|
||||
value={shareSelectedDays}
|
||||
onChange={(e) => onShareSelectedDaysChange(Number(e.target.value))}
|
||||
>
|
||||
|
||||
@@ -736,9 +736,14 @@ export function SharedSchedule() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="shared-schedule-page">
|
||||
<div className="shared-schedule-loading">
|
||||
<div className="shared-schedule-loading shared-schedule-loading-skeleton" aria-busy="true">
|
||||
<h1>💊 MedAssist-ng</h1>
|
||||
<p>{t("common.loading")}</p>
|
||||
<span className="screen-reader-only">{t("common.loading")}</span>
|
||||
<div className="skeleton-card">
|
||||
<span className="skeleton-line skeleton-line-long" />
|
||||
<span className="skeleton-line skeleton-line-medium" />
|
||||
<span className="skeleton-line skeleton-line-short" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
|
||||
import { allowsPillFormSelection, isLiquidContainerPackageType, isTubePackageType } from "../types/package-profiles";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
@@ -32,6 +33,43 @@ export function UserFilterModal({
|
||||
}: UserFilterModalProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const isLiquidMedication = (med: Medication): boolean => {
|
||||
const rawPackageType = med.packageType as unknown as string | null | undefined;
|
||||
return (
|
||||
isLiquidContainerPackageType(med.packageType) || rawPackageType === "liquid" || med.medicationForm === "liquid"
|
||||
);
|
||||
};
|
||||
|
||||
const getLiquidCountUnitLabel = (unit: "ml" | "tsp" | "tbsp" | null | undefined, usage: number): string => {
|
||||
if (unit === "tsp") return t("form.blisters.teaspoons", { count: Math.abs(usage) });
|
||||
if (unit === "tbsp") return t("form.blisters.tablespoons", { count: Math.abs(usage) });
|
||||
return t("form.packageAmountUnitMl");
|
||||
};
|
||||
|
||||
const formatIntakeUsageLabel = (
|
||||
med: Medication,
|
||||
usage: number,
|
||||
intakeUnit?: "ml" | "tsp" | "tbsp" | null
|
||||
): string => {
|
||||
if (isLiquidMedication(med)) {
|
||||
return `${formatNumber(usage)} ${getLiquidCountUnitLabel(intakeUnit, usage)}`;
|
||||
}
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(usage)} ${t("form.blisters.applications", { count: usage })}`;
|
||||
}
|
||||
return `${formatNumber(usage)} ${usage !== 1 ? t("common.pills") : t("common.pill")}`;
|
||||
};
|
||||
|
||||
const formatStockSummaryLabel = (med: Medication, currentStock: number, packageSize: number): string => {
|
||||
if (isLiquidMedication(med)) {
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${t("form.packageAmountUnitMl")}`;
|
||||
}
|
||||
if (isTubePackageType(med.packageType)) {
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${t("form.packageAmountUnitG")}`;
|
||||
}
|
||||
return `${formatNumber(currentStock)}/${formatNumber(packageSize)} ${packageSize === 1 ? t("common.pill") : t("common.pills")}`;
|
||||
};
|
||||
|
||||
useEscapeKey(!!selectedUser, onClose);
|
||||
|
||||
if (!selectedUser) return null;
|
||||
@@ -70,7 +108,7 @@ export function UserFilterModal({
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings, med.packageType)
|
||||
: getStockStatus(null, getMedTotal(med), settings, med.packageType);
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
const currentStock = medCoverage ? medCoverage.medsLeft : getMedTotal(med);
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
@@ -109,10 +147,12 @@ export function UserFilterModal({
|
||||
minute: "2-digit",
|
||||
});
|
||||
const intakeKey = `${intake.start}-${intake.usage}-${intake.every}-${intake.takenBy ?? ""}`;
|
||||
const intakeUnit = "intakeUnit" in intake ? intake.intakeUnit : undefined;
|
||||
return (
|
||||
<span key={intakeKey} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
{formatIntakeUsageLabel(med, intake.usage, intakeUnit)}
|
||||
{allowsPillFormSelection(med.packageType) &&
|
||||
med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{intake.every === 1 ? t("common.daily") : t("common.everyNDays", { count: intake.every })}{" "}
|
||||
{t("modal.at")} {timeStr}
|
||||
@@ -123,10 +163,7 @@ export function UserFilterModal({
|
||||
)}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
{currentStock}/{formatNumber(packageSize)}{" "}
|
||||
{packageSize === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
<span className="user-med-pills">{formatStockSummaryLabel(med, currentStock, packageSize)}</span>
|
||||
{status && <span className={`status-chip ${status.className}`}>{t(status.label)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user