feat: streamline dashboard UI and improve refill reminder (#86)
- Hide Reorder Reminder card when reminders are enabled (avoids redundancy with Reminder Bar) - Show all low stock medications in Reminder Bar instead of just the next one - Rename 'Reorder' to 'Refill' throughout the app - Make medication names clickable in Refill Reminder card (opens detail modal) - Add daysLeft display for each low stock medication - Update translations (EN + DE)
This commit is contained in:
@@ -19,6 +19,7 @@ export function SharedSchedule() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expiredData, setExpiredData] = useState<ExpiredLinkData | null>(null);
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||
const [showPastDays, setShowPastDays] = useState(false);
|
||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||
@@ -116,6 +117,7 @@ export function SharedSchedule() {
|
||||
}, [lightboxImage]);
|
||||
|
||||
// Load taken doses from server with polling for real-time sync
|
||||
// Separates taken and dismissed doses (like main app's useDoses hook)
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
async function loadTakenDoses() {
|
||||
@@ -123,12 +125,24 @@ export function SharedSchedule() {
|
||||
const res = await fetch(`/api/share/${token}/doses`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTakenDoses(new Set(data.doses.map((d: { doseId: string }) => d.doseId)));
|
||||
const taken = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses as Array<{ doseId: string; dismissed?: boolean }>) {
|
||||
if (d.dismissed) {
|
||||
dismissed.add(d.doseId);
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
}
|
||||
}
|
||||
setTakenDoses(taken);
|
||||
setDismissedDoses(dismissed);
|
||||
} else {
|
||||
setTakenDoses(new Set());
|
||||
setDismissedDoses(new Set());
|
||||
}
|
||||
} catch {
|
||||
setTakenDoses(new Set());
|
||||
setDismissedDoses(new Set());
|
||||
}
|
||||
}
|
||||
loadTakenDoses();
|
||||
@@ -538,10 +552,12 @@ export function SharedSchedule() {
|
||||
)
|
||||
);
|
||||
// Count missed doses (not taken AND not dismissed)
|
||||
// Note: SharedSchedule doesn't have updatedAt info, so we only check dismissed status
|
||||
// Check both: per-dose dismissed flag from API AND medication-level dismissedUntil
|
||||
const missedPastDoses = totalPastDoses.filter((id) => {
|
||||
if (takenDoses.has(id)) return false;
|
||||
// Check if this dose is dismissed
|
||||
// Check if this dose is dismissed via per-dose flag from API
|
||||
if (dismissedDoses.has(id)) return false;
|
||||
// Check if dismissed via medication-level dismissedUntil date
|
||||
const parts = id.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
@@ -584,9 +600,12 @@ export function SharedSchedule() {
|
||||
{showPastDays &&
|
||||
pastDays.map((day) => {
|
||||
// Helper to check if a dose ID is "done" (taken or dismissed)
|
||||
// Checks both: per-dose dismissed flag from API AND medication-level dismissedUntil
|
||||
const isDoseIdDone = (doseId: string) => {
|
||||
if (takenDoses.has(doseId)) return true;
|
||||
// Check if dismissed
|
||||
// Check if this dose is dismissed via per-dose flag from API
|
||||
if (dismissedDoses.has(doseId)) return true;
|
||||
// Check if dismissed via medication-level dismissedUntil date
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length >= 3) {
|
||||
const timestamp = parseInt(parts[2], 10);
|
||||
@@ -697,10 +716,11 @@ export function SharedSchedule() {
|
||||
<div className="doses-col">
|
||||
{item.doses.map((dose) => {
|
||||
const people = (dose.takenBy || []).length > 0 ? dose.takenBy : [null];
|
||||
const isDismissed = isDoseDismissed(dose.when, dose.medName);
|
||||
// Check both: medication-level dismissedUntil AND per-dose dismissed flag
|
||||
const isMedLevelDismissed = isDoseDismissed(dose.when, dose.medName);
|
||||
const allDone = people.every((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
return takenDoses.has(doseId) || isDismissed;
|
||||
return takenDoses.has(doseId) || dismissedDoses.has(doseId) || isMedLevelDismissed;
|
||||
});
|
||||
return (
|
||||
<div key={dose.id} className={`dose-item past ${allDone ? "all-taken" : ""}`}>
|
||||
@@ -713,7 +733,8 @@ export function SharedSchedule() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = takenDoses.has(doseId);
|
||||
const isDone = isTaken || isDismissed;
|
||||
const isPerDoseDismissed = dismissedDoses.has(doseId);
|
||||
const isDone = isTaken || isPerDoseDismissed || isMedLevelDismissed;
|
||||
return (
|
||||
<div key={doseId} className={`dose-person ${isDone ? "taken" : ""}`}>
|
||||
{person && <span className="person-name">{person}</span>}
|
||||
|
||||
@@ -17,11 +17,16 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"reorder": {
|
||||
"title": "Nachbestell-Erinnerung",
|
||||
"title": "Nachfüll-Erinnerung",
|
||||
"badge": "Bestandsüberwachung",
|
||||
"noMeds": "Noch keine Medikamente konfiguriert.",
|
||||
"allGood": "Alles in Ordnung, genug Vorrat.", "lowWarning": "Genug Vorrat, aber {{count}} Medikament wird knapp.",
|
||||
"lowWarning_other": "Genug Vorrat, aber {{count}} Medikamente werden knapp.", "sendReminder": "🔔 Erinnerung jetzt senden"
|
||||
"allGood": "Alles in Ordnung, genug Vorrat.",
|
||||
"lowWarning": "Genug Vorrat, aber {{meds}} wird knapp.",
|
||||
"lowWarning_other": "Genug Vorrat, aber {{meds}} werden knapp.",
|
||||
"lowWarningPrefix": "Genug Vorrat, aber",
|
||||
"lowWarningSuffix": "wird knapp.",
|
||||
"lowWarningSuffix_other": "werden knapp.",
|
||||
"sendReminder": "🔔 Erinnerung jetzt senden"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Medikamentenübersicht",
|
||||
@@ -64,8 +69,8 @@
|
||||
"inDays_one": "in {{days}} Tag",
|
||||
"inDays_other": "in {{days}} Tagen",
|
||||
"noRemindersNeeded": "Keine Erinnerungen nötig",
|
||||
"needReorder": "{{count}} Medikament nachbestellen",
|
||||
"needReorder_other": "{{count}} Medikamente nachbestellen",
|
||||
"needRefill": "{{count}} Medikament nachfüllen",
|
||||
"needRefill_other": "{{count}} Medikamente nachfüllen",
|
||||
"emptyStock": "{{count}} Medikament leer",
|
||||
"emptyStock_other": "{{count}} Medikamente leer",
|
||||
"lowWarning": "{{count}} Medikament wird knapp",
|
||||
@@ -81,7 +86,9 @@
|
||||
"criticalMeds": "{{count}} Medikament kritisch",
|
||||
"criticalMeds_other": "{{count}} Medikamente kritisch",
|
||||
"lowMeds": "{{count}} Medikament knapp",
|
||||
"lowMeds_other": "{{count}} Medikamente knapp"
|
||||
"lowMeds_other": "{{count}} Medikamente knapp",
|
||||
"daysLeft": "{{days}} Tag übrig",
|
||||
"daysLeft_other": "{{days}} Tage übrig"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
},
|
||||
"dashboard": {
|
||||
"reorder": {
|
||||
"title": "Reorder Reminder",
|
||||
"title": "Refill Reminder",
|
||||
"badge": "Stock watch",
|
||||
"noMeds": "No medications configured yet.",
|
||||
"allGood": "All good, enough stock.",
|
||||
"lowWarning": "Enough stock for now, but {{count}} medication is running low.",
|
||||
"lowWarning_other": "Enough stock for now, but {{count}} medications are running low.",
|
||||
"lowWarning": "Enough stock for now, but {{meds}} is running low.",
|
||||
"lowWarning_other": "Enough stock for now, but {{meds}} are running low.",
|
||||
"lowWarningPrefix": "Enough stock for now, but",
|
||||
"lowWarningSuffix": "is running low.",
|
||||
"lowWarningSuffix_other": "are running low.",
|
||||
"sendReminder": "🔔 Send Reminder Now"
|
||||
},
|
||||
"overview": {
|
||||
@@ -66,8 +69,8 @@
|
||||
"inDays_one": "in {{days}} day",
|
||||
"inDays_other": "in {{days}} days",
|
||||
"noRemindersNeeded": "No reminders needed",
|
||||
"needReorder": "{{count}} med needs reorder",
|
||||
"needReorder_other": "{{count}} meds need reorder",
|
||||
"needRefill": "{{count}} med needs refill",
|
||||
"needRefill_other": "{{count}} meds need refill",
|
||||
"emptyStock": "{{count}} med is empty",
|
||||
"emptyStock_other": "{{count}} meds are empty",
|
||||
"lowWarning": "{{count}} medication running low",
|
||||
@@ -83,7 +86,9 @@
|
||||
"criticalMeds": "{{count}} medication critical",
|
||||
"criticalMeds_other": "{{count}} medications critical",
|
||||
"lowMeds": "{{count}} medication low",
|
||||
"lowMeds_other": "{{count}} medications low"
|
||||
"lowMeds_other": "{{count}} medications low",
|
||||
"daysLeft": "{{days}} day left",
|
||||
"daysLeft_other": "{{days}} days left"
|
||||
}
|
||||
},
|
||||
"table": {
|
||||
|
||||
@@ -105,7 +105,7 @@ function getReminderStatusData(
|
||||
locale: string
|
||||
): {
|
||||
status: { text: string; className: string };
|
||||
next: { name: string; days: number } | null;
|
||||
lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[];
|
||||
lastSent: { date: string; medName: string | null; takenBy: string | null } | null;
|
||||
} {
|
||||
const criticalCount = lowCoverage.length;
|
||||
@@ -134,17 +134,28 @@ function getReminderStatusData(
|
||||
};
|
||||
}
|
||||
|
||||
// Find next medication to hit reminder threshold
|
||||
const nextToRunOut = allCoverage
|
||||
.filter((c) => c.daysLeft !== null && c.daysLeft > reminderDaysBefore)
|
||||
.sort((a, b) => (a.daysLeft ?? Infinity) - (b.daysLeft ?? Infinity))[0];
|
||||
// Collect all low stock medications (critical + low)
|
||||
const lowStockMeds: { name: string; daysLeft: number; isCritical: boolean }[] = [];
|
||||
|
||||
let next: { name: string; days: number } | null = null;
|
||||
if (nextToRunOut && nextToRunOut.daysLeft !== null) {
|
||||
const daysUntilReminder = Math.round(nextToRunOut.daysLeft - reminderDaysBefore);
|
||||
next = { name: nextToRunOut.name, days: daysUntilReminder };
|
||||
// Add critical meds (from lowCoverage - these are ≤3 days)
|
||||
for (const c of lowCoverage) {
|
||||
if (c.daysLeft !== null) {
|
||||
lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Add low but not critical meds
|
||||
for (const c of allCoverage) {
|
||||
if (c.medsLeft <= 0) continue;
|
||||
if (c.daysLeft === null) continue;
|
||||
if (c.daysLeft < lowStockDays && c.daysLeft > 3) {
|
||||
lowStockMeds.push({ name: c.name, daysLeft: Math.round(c.daysLeft), isCritical: false });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by days left (most urgent first)
|
||||
lowStockMeds.sort((a, b) => a.daysLeft - b.daysLeft);
|
||||
|
||||
// Parse last sent info
|
||||
let lastSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||
if (lastAutoEmailSent) {
|
||||
@@ -163,7 +174,7 @@ function getReminderStatusData(
|
||||
};
|
||||
}
|
||||
|
||||
return { status, next, lastSent };
|
||||
return { status, lowStockMeds, lastSent };
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
@@ -273,167 +284,198 @@ export function DashboardPage() {
|
||||
{reminderData.status.text}
|
||||
</span>
|
||||
</div>
|
||||
<div className="reminder-status-details">
|
||||
{stockRemindersEnabled && reminderData.next && (
|
||||
<div className="reminder-status-row">
|
||||
<span className="reminder-status-label">{t("dashboard.reminders.next")}:</span>
|
||||
<span className="reminder-status-value">
|
||||
{reminderData.next.name}{" "}
|
||||
{t("dashboard.reminders.inDays", { count: reminderData.next.days, days: reminderData.next.days })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{intakeRemindersEnabled && reminderData.lastSent && (
|
||||
<div className="reminder-status-row">
|
||||
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
|
||||
<span className="reminder-status-value">
|
||||
{reminderData.lastSent.medName && (
|
||||
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
|
||||
)}
|
||||
{reminderData.lastSent.takenBy && (
|
||||
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
|
||||
)}
|
||||
<span className="reminder-date">{reminderData.lastSent.date}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(reminderData.lowStockMeds.length > 0 || (intakeRemindersEnabled && reminderData.lastSent)) && (
|
||||
<div className="reminder-status-details">
|
||||
{stockRemindersEnabled && reminderData.lowStockMeds.length > 0 && (
|
||||
<div className="reminder-low-stock-list">
|
||||
{reminderData.lowStockMeds.map((med) => (
|
||||
<div key={med.name} className={`reminder-low-stock-item ${med.isCritical ? "critical" : ""}`}>
|
||||
<span className="reminder-med-name">{med.name}</span>
|
||||
<span className="reminder-days-left">
|
||||
{t("dashboard.reminders.daysLeft", { count: med.daysLeft, days: med.daysLeft })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{intakeRemindersEnabled && reminderData.lastSent && (
|
||||
<div className="reminder-status-row">
|
||||
<span className="reminder-status-label">{t("dashboard.reminders.lastSent")}:</span>
|
||||
<span className="reminder-status-value">
|
||||
{reminderData.lastSent.medName && (
|
||||
<span className="reminder-med-name">{reminderData.lastSent.medName}</span>
|
||||
)}
|
||||
{reminderData.lastSent.takenBy && (
|
||||
<span className="reminder-taken-by">({reminderData.lastSent.takenBy})</span>
|
||||
)}
|
||||
<span className="reminder-date">{reminderData.lastSent.date}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<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>;
|
||||
}
|
||||
|
||||
// Count medications with "Low" stock status (based on lowStockDays setting)
|
||||
const lowStockCount = coverage.all.filter((c) => {
|
||||
if (c.medsLeft <= 0) return true; // out of stock
|
||||
if (c.daysLeft === null) return false; // no schedule
|
||||
return c.daysLeft < settings.lowStockDays;
|
||||
}).length;
|
||||
|
||||
if (coverage.low.length === 0) {
|
||||
// No critical meds (≤3 days)
|
||||
if (lowStockCount === 0) {
|
||||
// All good - everything is Normal or High
|
||||
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
|
||||
} else {
|
||||
// Some meds are Low but not critical
|
||||
return <p className="warning-text">{t("dashboard.reorder.lowWarning", { count: lowStockCount })}</p>;
|
||||
{/* Reorder Reminder card: Only show when reminders are NOT enabled (otherwise Reminder Bar shows the same info) */}
|
||||
{!anyRemindersEnabled && (
|
||||
<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>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table table-7">
|
||||
<div className="table-head">
|
||||
<span>{t("table.name")}</span>
|
||||
<span>{t("table.fullBlisters")}</span>
|
||||
<span>{t("table.openBlister")}</span>
|
||||
<span>{t("table.daysLeft")}</span>
|
||||
<span>{t("table.status")}</span>
|
||||
<span>{t("table.runsOut")}</span>
|
||||
<span>{t("table.autoRemind")}</span>
|
||||
</div>
|
||||
{coverage.low.map((row) => {
|
||||
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
|
||||
const med = meds.find((m) => m.name === row.name);
|
||||
const textClass =
|
||||
status.className === "danger"
|
||||
? "danger-text"
|
||||
: status.className === "warning"
|
||||
? "warning-text"
|
||||
: "success-text";
|
||||
const stock = getBlisterStock(
|
||||
Math.round(row.medsLeft),
|
||||
med?.pillsPerBlister ?? 1,
|
||||
med?.looseTablets ?? 0,
|
||||
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||
);
|
||||
return (
|
||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy &&
|
||||
med.takenBy.length > 0 &&
|
||||
med.takenBy.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="taken-by-badge clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
</span>
|
||||
))}
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
{med?.notes && (
|
||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
)}
|
||||
// Count medications with "Low" stock status (based on lowStockDays setting)
|
||||
const lowStockMeds = coverage.all.filter((c) => {
|
||||
if (c.medsLeft <= 0) return true; // out of stock
|
||||
if (c.daysLeft === null) return false; // no schedule
|
||||
return c.daysLeft < settings.lowStockDays;
|
||||
});
|
||||
const lowStockCount = lowStockMeds.length;
|
||||
const lowStockNames = lowStockMeds.map((c) => c.name).join(", ");
|
||||
|
||||
if (coverage.low.length === 0) {
|
||||
// No critical meds (≤3 days)
|
||||
if (lowStockCount === 0) {
|
||||
// All good - everything is Normal or High
|
||||
return <p className="success-text">{t("dashboard.reorder.allGood")}</p>;
|
||||
} else {
|
||||
// Some meds are Low but not critical - render with clickable med names
|
||||
return (
|
||||
<p className="warning-text">
|
||||
{t("dashboard.reorder.lowWarningPrefix")}{" "}
|
||||
{lowStockMeds.map((c, idx) => {
|
||||
const med = meds.find((m) => m.name === c.name);
|
||||
return (
|
||||
<span key={c.name}>
|
||||
{idx > 0 && ", "}
|
||||
<span className="med-link clickable" onClick={() => med && openMedDetail(med)}>
|
||||
{c.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.fullBlisters")} className={textClass}>
|
||||
{formatFullBlisters(stock.fullBlisters, t)}
|
||||
</span>
|
||||
<span data-label={t("table.openBlister")} className={textClass}>
|
||||
{formatOpenBlisterAndLoose(
|
||||
stock.openBlisterPills,
|
||||
stock.loosePills,
|
||||
med?.pillsPerBlister ?? 1,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.days")} className={textClass}>
|
||||
{formatNumber(row.daysLeft)}
|
||||
</span>
|
||||
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
|
||||
{t(status.label)}
|
||||
</span>
|
||||
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
|
||||
<span data-label={t("table.autoRemind")} className="next-reminder-date">
|
||||
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
||||
<div className="email-send-action">
|
||||
<button type="button" className="ghost" onClick={sendReminderEmail} disabled={sendingReminderEmail}>
|
||||
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
|
||||
</button>
|
||||
{reminderEmailResult && (
|
||||
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{reminderEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}{" "}
|
||||
{t("dashboard.reorder.lowWarningSuffix", { count: lowStockCount })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table table-7">
|
||||
<div className="table-head">
|
||||
<span>{t("table.name")}</span>
|
||||
<span>{t("table.fullBlisters")}</span>
|
||||
<span>{t("table.openBlister")}</span>
|
||||
<span>{t("table.daysLeft")}</span>
|
||||
<span>{t("table.status")}</span>
|
||||
<span>{t("table.runsOut")}</span>
|
||||
<span>{t("table.autoRemind")}</span>
|
||||
</div>
|
||||
{coverage.low.map((row) => {
|
||||
const status = getStockStatus(row.daysLeft, row.medsLeft, settings);
|
||||
const med = meds.find((m) => m.name === row.name);
|
||||
const textClass =
|
||||
status.className === "danger"
|
||||
? "danger-text"
|
||||
: status.className === "warning"
|
||||
? "warning-text"
|
||||
: "success-text";
|
||||
const stock = getBlisterStock(
|
||||
Math.round(row.medsLeft),
|
||||
med?.pillsPerBlister ?? 1,
|
||||
med?.looseTablets ?? 0,
|
||||
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||
);
|
||||
return (
|
||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||
<MedicationAvatar name={row.name} imageUrl={med?.imageUrl} />
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy &&
|
||||
med.takenBy.length > 0 &&
|
||||
med.takenBy.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="taken-by-badge clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
</span>
|
||||
))}
|
||||
{(med?.intakeRemindersEnabled || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{med?.intakeRemindersEnabled && (
|
||||
<span
|
||||
className="reminder-icon info-tooltip"
|
||||
data-tooltip={t("tooltips.intakeReminders")}
|
||||
>
|
||||
🔔
|
||||
</span>
|
||||
)}
|
||||
{med?.notes && (
|
||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.fullBlisters")} className={textClass}>
|
||||
{formatFullBlisters(stock.fullBlisters, t)}
|
||||
</span>
|
||||
<span data-label={t("table.openBlister")} className={textClass}>
|
||||
{formatOpenBlisterAndLoose(
|
||||
stock.openBlisterPills,
|
||||
stock.loosePills,
|
||||
med?.pillsPerBlister ?? 1,
|
||||
t
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.days")} className={textClass}>
|
||||
{formatNumber(row.daysLeft)}
|
||||
</span>
|
||||
<span data-label={t("table.status")} className={`status-chip ${status.className}`}>
|
||||
{t(status.label)}
|
||||
</span>
|
||||
<span data-label={t("table.runsOut")}>{row.depletionDate ?? "-"}</span>
|
||||
<span data-label={t("table.autoRemind")} className="next-reminder-date">
|
||||
{getNextReminderForMed(row, settings.reminderDaysBefore, getSystemLocale(i18n.language))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
{(settings.emailEnabled || settings.shoutrrrEnabled) && (
|
||||
<div className="email-send-action">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost"
|
||||
onClick={sendReminderEmail}
|
||||
disabled={sendingReminderEmail}
|
||||
>
|
||||
{sendingReminderEmail ? t("common.sending") : t("dashboard.reorder.sendReminder")}
|
||||
</button>
|
||||
{reminderEmailResult && (
|
||||
<span className={reminderEmailResult.success ? "success-text" : "danger-text"}>
|
||||
{reminderEmailResult.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid">
|
||||
<article className="card">
|
||||
|
||||
+52
-4
@@ -195,7 +195,7 @@ body.modal-open {
|
||||
.reminder-status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -212,11 +212,10 @@ body.modal-open {
|
||||
}
|
||||
|
||||
.reminder-status-badge {
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.reminder-status-badge.success {
|
||||
@@ -282,6 +281,52 @@ body.modal-open {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reminder-low-stock-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.reminder-low-stock-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.reminder-low-stock-item .reminder-med-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.reminder-low-stock-item .reminder-days-left {
|
||||
color: var(--warning);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.reminder-low-stock-item.critical .reminder-days-left {
|
||||
color: var(--danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.med-link {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.med-link.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.med-link.clickable:hover {
|
||||
color: var(--accent);
|
||||
text-decoration-style: solid;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.reminder-status-bar {
|
||||
padding: 0.75rem;
|
||||
@@ -300,6 +345,9 @@ body.modal-open {
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.reminder-low-stock-list {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
|
||||
@@ -577,15 +577,15 @@ describe("DashboardPage with email notifications", () => {
|
||||
expect(statusBar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows reminder email button when there are low stock meds", () => {
|
||||
it("hides reorder reminder card when reminders are enabled (to avoid redundancy)", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<DashboardPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Should show send reminder button
|
||||
expect(screen.getByText(/dashboard\.reorder\.sendReminder/i)).toBeInTheDocument();
|
||||
// Reorder card should NOT be shown when reminders are active (Reminder Bar shows the info instead)
|
||||
expect(screen.queryByText(/dashboard\.reorder\.sendReminder/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -466,7 +466,7 @@ describe("getReminderStatusText", () => {
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(7, 30, [], [lowMed], null, null, null, mockT, "en");
|
||||
expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needReorder"))).toBe(true);
|
||||
expect(result.lines.some((l) => l.text.includes("lowWarning") || l.text.includes("needRefill"))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles intake reminder type with push channel", () => {
|
||||
@@ -497,7 +497,7 @@ describe("getReminderStatusText", () => {
|
||||
expect(result.lines[0].className).toBe("danger-text");
|
||||
});
|
||||
|
||||
it("shows needReorder when below critical threshold", () => {
|
||||
it("shows needRefill when below critical threshold", () => {
|
||||
const criticalMed: Coverage = {
|
||||
name: "Critical",
|
||||
medsLeft: 5,
|
||||
@@ -508,7 +508,7 @@ describe("getReminderStatusText", () => {
|
||||
};
|
||||
|
||||
const result = getReminderStatusText(7, 30, [criticalMed], [criticalMed], null, null, null, mockT, "en");
|
||||
expect(result.lines.some((l) => l.text.includes("needReorder"))).toBe(true);
|
||||
expect(result.lines.some((l) => l.text.includes("needRefill"))).toBe(true);
|
||||
});
|
||||
|
||||
it("shows low warning when below low threshold but above critical", () => {
|
||||
|
||||
@@ -237,7 +237,7 @@ export function getReminderStatusText(
|
||||
});
|
||||
if (medsNeedingReminder.length > 0) {
|
||||
lines.push({
|
||||
text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`,
|
||||
text: `⚠ ${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
|
||||
className: "danger-text",
|
||||
});
|
||||
}
|
||||
@@ -255,7 +255,7 @@ export function getReminderStatusText(
|
||||
|
||||
if (medsNeedingReminder.length > 0) {
|
||||
lines.push({
|
||||
text: `⚠ ${t("dashboard.reminders.needReorder", { count: medsNeedingReminder.length })}`,
|
||||
text: `⚠ ${t("dashboard.reminders.needRefill", { count: medsNeedingReminder.length })}`,
|
||||
className: "danger-text",
|
||||
strong: true,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user