feat: add prescription refills column to planner table and email (#207)
- Add 6th column 'Prescription refills' to frontend Planner table - Add matching column to backend planner email (HTML + plaintext) - Show remaining refills for meds with prescription tracking, '–' otherwise - Add backend translations for new column header (EN + DE) - Add frontend i18n keys for prescription refills column - Update planner tests with medications table schema Closes #203
This commit is contained in:
@@ -167,11 +167,13 @@ type TranslationKeys = {
|
||||
medication: string;
|
||||
usage: string;
|
||||
needed: string;
|
||||
prescriptionRefills: string;
|
||||
available: string;
|
||||
status: string;
|
||||
};
|
||||
statusEnough: string;
|
||||
statusEmpty: string;
|
||||
prescriptionNotApplicable: string;
|
||||
};
|
||||
// Common
|
||||
common: {
|
||||
@@ -286,11 +288,13 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
medication: "Medication",
|
||||
usage: "Usage",
|
||||
needed: "Blisters needed",
|
||||
prescriptionRefills: "Prescription refills",
|
||||
available: "Available",
|
||||
status: "Status",
|
||||
},
|
||||
statusEnough: "✓ Enough",
|
||||
statusEmpty: "✗ Empty",
|
||||
prescriptionNotApplicable: "–",
|
||||
},
|
||||
common: {
|
||||
pill: "pill",
|
||||
@@ -405,11 +409,13 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
medication: "Medikament",
|
||||
usage: "Verbrauch",
|
||||
needed: "Blister benötigt",
|
||||
prescriptionRefills: "Rezept-Nachfüllungen",
|
||||
available: "Verfügbar",
|
||||
status: "Status",
|
||||
},
|
||||
statusEnough: "✓ Ausreichend",
|
||||
statusEmpty: "✗ Leer",
|
||||
prescriptionNotApplicable: "–",
|
||||
},
|
||||
common: {
|
||||
pill: "Tablette",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications } from "../db/schema.js";
|
||||
import {
|
||||
getDateLocale,
|
||||
getFooterHtml,
|
||||
@@ -132,6 +135,21 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const summaryText = outOfStockCount > 0 ? t(dc.summaryOutOfStock, { count: outOfStockCount }) : dc.summaryAllOk;
|
||||
|
||||
// Load prescription data for medications referenced in planner rows
|
||||
const medIds = rows.map((r) => r.medicationId).filter(Boolean);
|
||||
const allMeds =
|
||||
medIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
id: medications.id,
|
||||
prescriptionEnabled: medications.prescriptionEnabled,
|
||||
prescriptionRemainingRefills: medications.prescriptionRemainingRefills,
|
||||
})
|
||||
.from(medications)
|
||||
.where(eq(medications.userId, userId))
|
||||
: [];
|
||||
const prescriptionMap = new Map(allMeds.map((m) => [m.id, m]));
|
||||
|
||||
// Build plain text (shared between email and push)
|
||||
const plainText = `${dc.title}
|
||||
${t(dc.description, { from: fromDate, until: untilDate })}
|
||||
@@ -143,12 +161,16 @@ ${rows
|
||||
const isBottle = r.packageType === "bottle";
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const needed = isBottle ? "–" : `${r.blistersNeeded} × ${r.blisterSize}`;
|
||||
const medPrescription = prescriptionMap.get(r.medicationId);
|
||||
const rxRefills = medPrescription?.prescriptionEnabled
|
||||
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
||||
: dc.prescriptionNotApplicable;
|
||||
const loosePills = Math.round((Number(r.loosePills) || 0) * 10) / 10;
|
||||
const available = isBottle
|
||||
? `${loosePills} ${tr.common.pills}`
|
||||
: `${r.fullBlisters} ${tr.common.blisters}${loosePills > 0 ? ` + ${loosePills} ${tr.common.pills}` : ""}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
return `${r.medicationName}: ${usage}, ${needed}, ${available} - ${status}`;
|
||||
return `${r.medicationName}: ${usage}, ${needed}, ${dc.tableHeaders.prescriptionRefills}: ${rxRefills}, ${available} - ${status}`;
|
||||
})
|
||||
.join("\n")}
|
||||
|
||||
@@ -182,6 +204,12 @@ ${getFooterPlain(language)}`;
|
||||
// "Blisters needed" column: dash for bottles
|
||||
const neededCell = isBottle ? "–" : `${safeBlistersNeeded} × ${safeBlisterSize}`;
|
||||
|
||||
// "Prescription refills" column
|
||||
const medPrescription = prescriptionMap.get(row.medicationId);
|
||||
const rxCell = medPrescription?.prescriptionEnabled
|
||||
? String(medPrescription.prescriptionRemainingRefills ?? 0)
|
||||
: dc.prescriptionNotApplicable;
|
||||
|
||||
// "Available" column: match frontend format
|
||||
let availableCell: string;
|
||||
if (isBottle) {
|
||||
@@ -198,6 +226,7 @@ ${getFooterPlain(language)}`;
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${safeName}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;"><strong>${safePlannerUsage}</strong> ${tr.common.pills}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${neededCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${rxCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${availableCell}</td>
|
||||
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">
|
||||
<span style="display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; ${
|
||||
@@ -234,6 +263,7 @@ ${getFooterPlain(language)}`;
|
||||
<th style="padding: 10px 12px; text-align: left; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.medication}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.usage}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.needed}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.prescriptionRefills}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.available}</th>
|
||||
<th style="padding: 10px 12px; text-align: center; font-size: 11px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.05em; white-space: nowrap;">${dc.tableHeaders.status}</th>
|
||||
</tr>
|
||||
|
||||
@@ -86,6 +86,39 @@ async function createSchema(client: Client) {
|
||||
is_active integer NOT NULL DEFAULT 1,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS medications (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
name text NOT NULL,
|
||||
generic_name text,
|
||||
taken_by_json text NOT NULL DEFAULT '[]',
|
||||
package_type text NOT NULL DEFAULT 'blister',
|
||||
pack_count integer NOT NULL DEFAULT 1,
|
||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||
total_pills integer,
|
||||
loose_tablets integer NOT NULL DEFAULT 0,
|
||||
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||
last_stock_correction_at integer,
|
||||
pill_weight_mg integer,
|
||||
dose_unit text DEFAULT 'mg',
|
||||
usage_json text NOT NULL DEFAULT '[]',
|
||||
every_json text NOT NULL DEFAULT '[]',
|
||||
start_json text NOT NULL DEFAULT '[]',
|
||||
intakes_json text NOT NULL DEFAULT '[]',
|
||||
image_url text,
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
prescription_low_refill_threshold integer NOT NULL DEFAULT 1,
|
||||
prescription_expiry_date text,
|
||||
dismissed_until text,
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"medication": "Medikament",
|
||||
"usage": "Verbrauch",
|
||||
"blistersNeeded": "Blister benötigt",
|
||||
"prescriptionRefills": "Rezept-Nachfüllungen",
|
||||
"blisters": "Blister",
|
||||
"available": "Verfügbar"
|
||||
}
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"medication": "Medication",
|
||||
"usage": "Usage",
|
||||
"blistersNeeded": "Blisters needed",
|
||||
"prescriptionRefills": "Prescription refills",
|
||||
"blisters": "Blisters",
|
||||
"available": "Available"
|
||||
}
|
||||
|
||||
@@ -196,16 +196,19 @@ export function PlannerPage() {
|
||||
</form>
|
||||
{plannerRows.length > 0 && (
|
||||
<>
|
||||
<div className="table">
|
||||
<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.name === row.medicationName);
|
||||
const med =
|
||||
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
|
||||
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
|
||||
return (
|
||||
<div key={row.medicationId} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||
<span data-label={t("planner.table.medication")} className="cell-with-avatar">
|
||||
@@ -219,6 +222,7 @@ export function PlannerPage() {
|
||||
<span data-label={t("planner.table.blisters")}>
|
||||
{row.packageType === "bottle" ? "–" : `${row.blistersNeeded} × ${row.blisterSize}`}
|
||||
</span>
|
||||
<span data-label={t("planner.table.prescriptionRefills")}>{remainingRefills ?? "–"}</span>
|
||||
<span data-label={t("planner.table.available")}>
|
||||
{row.packageType === "bottle" ? (
|
||||
`${Math.round(row.loosePills * 10) / 10} ${Math.round(row.loosePills * 10) / 10 === 1 ? t("common.pill") : t("common.pills")}`
|
||||
|
||||
Reference in New Issue
Block a user