feat: obsolete medication archiving, start date, and UI improvements (#215)
* feat: obsolete medication archiving, start date, and UI improvements - Add soft-archive (obsolete) for medications with dedicated section and toggle - Add medication start date field with date picker and validation - Add obsolete/reactivate API endpoints with proper auth - Filter obsolete meds from schedule, coverage, planner, and notifications - Improve UserFilterModal with intake schedules, stock badges, and click-to-open - Improve dashboard taken-by badges with per-intake bell icons - Add Escape key support to ConfirmModal and MobileEditModal - Fix Lightbox close button positioning near image - Add read-only mode support for MobileEditModal - DB migrations: 0008 (is_obsolete, obsolete_at), 0009 (medication_start_date) - All user-facing text uses i18n keys (en + de) * test: fix tests for obsolete medications and UI changes - Backend: add is_obsolete, obsolete_at, medication_start_date columns to test schemas - Backend: add test medication inserts in planner tests for active-med filtering - Frontend: update useMedications URL to include includeObsolete param - Frontend: fix MobileEditModal selectors and validation assertions - Frontend: add onClearUser prop to UserFilterModal test renders - Frontend: fix MedicationsPage and DashboardPage test assertions
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
|
||||
ALTER TABLE `medications` ADD `obsolete_at` integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
|
||||
@@ -57,6 +57,20 @@
|
||||
"when": 1770659669121,
|
||||
"tag": "0007_add_share_stock_status",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1771160400000,
|
||||
"tag": "0008_add_obsolete_medications",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1771164000000,
|
||||
"tag": "0009_add_medication_start_date",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -119,6 +119,11 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
|
||||
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
|
||||
// Added for soft-archiving medications (without deleting history)
|
||||
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
|
||||
// Added for explicit medication lifecycle start date
|
||||
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
|
||||
// Added for more detailed reminder info display
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
|
||||
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
|
||||
|
||||
@@ -47,6 +47,9 @@ export const medications = sqliteTable("medications", {
|
||||
expiryDate: text("expiry_date"),
|
||||
notes: text("notes"),
|
||||
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
medicationStartDate: text("medication_start_date").notNull().default(""),
|
||||
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
|
||||
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
|
||||
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
|
||||
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
|
||||
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
|
||||
|
||||
@@ -49,9 +49,12 @@ const medicationExportSchema = z.object({
|
||||
pillWeightMg: z.number().int().nullable().optional(),
|
||||
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
|
||||
schedules: z.array(scheduleSchema).default([]),
|
||||
medicationStartDate: z.string().nullable().optional(),
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
intakeRemindersEnabled: z.boolean().default(false),
|
||||
isObsolete: z.boolean().default(false),
|
||||
obsoleteAt: z.string().nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
|
||||
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
|
||||
@@ -289,9 +292,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
pillWeightMg: med.pillWeightMg,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
schedules: parseIntakesForExport(med),
|
||||
medicationStartDate: med.medicationStartDate || null,
|
||||
expiryDate: med.expiryDate,
|
||||
notes: med.notes,
|
||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
|
||||
@@ -515,6 +521,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
@@ -522,6 +529,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
|
||||
@@ -32,6 +32,9 @@ const blisterSchema = z.object({
|
||||
|
||||
const packageTypeSchema = z.enum(["blister", "bottle"]).default("blister");
|
||||
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
|
||||
const medicationStartDateSchema = z
|
||||
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
|
||||
.optional();
|
||||
|
||||
const medicationSchema = z
|
||||
.object({
|
||||
@@ -46,6 +49,7 @@ const medicationSchema = z
|
||||
looseTablets: z.number().int().min(0).default(0),
|
||||
pillWeightMg: z.number().nonnegative().nullable().optional(),
|
||||
doseUnit: doseUnitSchema,
|
||||
medicationStartDate: medicationStartDateSchema,
|
||||
expiryDate: z.string().nullable().optional(),
|
||||
notes: z.string().max(2000).nullable().optional(),
|
||||
prescriptionEnabled: z.boolean().default(false),
|
||||
@@ -59,6 +63,19 @@ const medicationSchema = z
|
||||
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
|
||||
})
|
||||
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
|
||||
.refine(
|
||||
(data) => {
|
||||
const startDate = data.medicationStartDate ?? "";
|
||||
if (!startDate) return true;
|
||||
|
||||
const scheduleStarts = data.intakes?.map((i) => i.start) ?? data.blisters?.map((b) => b.start) ?? [];
|
||||
return scheduleStarts.every((scheduleStart) => scheduleStart.slice(0, 10) >= startDate);
|
||||
},
|
||||
{
|
||||
message: "Medication start date must be on or before all intake dates",
|
||||
path: ["medicationStartDate"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (!data.prescriptionEnabled) return true;
|
||||
@@ -103,9 +120,13 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
return authUser.id;
|
||||
}
|
||||
|
||||
app.get("/medications", async (request, reply) => {
|
||||
app.get<{ Querystring: { includeObsolete?: string } }>("/medications", async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const includeObsolete = request.query.includeObsolete === "true";
|
||||
const whereClause = includeObsolete
|
||||
? eq(medications.userId, userId)
|
||||
: and(eq(medications.userId, userId), eq(medications.isObsolete, false));
|
||||
const rows = await db.select().from(medications).where(whereClause).orderBy(medications.id);
|
||||
return rows.map((row) => {
|
||||
// Parse intakes from new format, falling back to legacy
|
||||
const intakes = parseIntakesJson(
|
||||
@@ -129,6 +150,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: row.pillWeightMg,
|
||||
doseUnit: row.doseUnit ?? "mg",
|
||||
medicationStartDate: row.medicationStartDate || null,
|
||||
intakes, // New unified format with per-intake takenBy
|
||||
// Legacy blisters format (for backward compat with frontend during transition)
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
@@ -136,6 +158,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
expiryDate: row.expiryDate,
|
||||
notes: row.notes,
|
||||
intakeRemindersEnabled: row.intakeRemindersEnabled ?? false,
|
||||
isObsolete: row.isObsolete ?? false,
|
||||
obsoleteAt: row.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: row.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: row.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: row.prescriptionRemainingRefills ?? null,
|
||||
@@ -164,6 +188,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -222,6 +247,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -252,12 +278,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: inserted.pillWeightMg,
|
||||
doseUnit: inserted.doseUnit ?? "mg",
|
||||
medicationStartDate: inserted.medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: inserted.imageUrl,
|
||||
expiryDate: inserted.expiryDate,
|
||||
notes: inserted.notes,
|
||||
intakeRemindersEnabled: inserted.intakeRemindersEnabled,
|
||||
isObsolete: inserted.isObsolete ?? false,
|
||||
obsoleteAt: inserted.obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: inserted.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: inserted.prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: inserted.prescriptionRemainingRefills ?? null,
|
||||
@@ -294,6 +323,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg,
|
||||
doseUnit,
|
||||
medicationStartDate,
|
||||
expiryDate,
|
||||
notes,
|
||||
prescriptionEnabled,
|
||||
@@ -362,6 +392,7 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
looseTablets,
|
||||
pillWeightMg: pillWeightMg || null,
|
||||
doseUnit: doseUnit ?? "mg",
|
||||
medicationStartDate: medicationStartDate ?? "",
|
||||
expiryDate: expiryDate || null,
|
||||
notes: notes || null,
|
||||
prescriptionEnabled: prescriptionEnabled ?? false,
|
||||
@@ -516,12 +547,15 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||
pillWeightMg: result[0].pillWeightMg,
|
||||
doseUnit: result[0].doseUnit ?? "mg",
|
||||
medicationStartDate: result[0].medicationStartDate || null,
|
||||
intakes,
|
||||
blisters: intakes.map((i) => ({ usage: i.usage, every: i.every, start: i.start })),
|
||||
imageUrl: result[0].imageUrl,
|
||||
expiryDate: result[0].expiryDate,
|
||||
notes: result[0].notes,
|
||||
intakeRemindersEnabled: result[0].intakeRemindersEnabled,
|
||||
isObsolete: result[0].isObsolete ?? false,
|
||||
obsoleteAt: result[0].obsoleteAt?.toISOString() ?? null,
|
||||
prescriptionEnabled: result[0].prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: result[0].prescriptionAuthorizedRefills ?? null,
|
||||
prescriptionRemainingRefills: result[0].prescriptionRemainingRefills ?? null,
|
||||
@@ -531,6 +565,64 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/obsolete", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: true,
|
||||
obsoleteAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>("/medications/:id/reactivate", async (req, reply) => {
|
||||
const idNum = Number(req.params.id);
|
||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||
if (!existing) return reply.notFound();
|
||||
|
||||
const [updated] = await db
|
||||
.update(medications)
|
||||
.set({
|
||||
isObsolete: false,
|
||||
obsoleteAt: null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||
.returning();
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
isObsolete: updated.isObsolete ?? false,
|
||||
obsoleteAt: updated.obsoleteAt?.toISOString() ?? null,
|
||||
updatedAt: updated.updatedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>(
|
||||
@@ -678,7 +770,11 @@ export async function medicationRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
// Get all taken doses for this user to calculate actual consumption
|
||||
const takenDoses = await db
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -103,6 +103,16 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ id: medications.id })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedIds = new Set(activeMeds.map((med) => med.id));
|
||||
const activeRows = rows.filter((row) => activeMedIds.has(row.medicationId));
|
||||
if (activeRows.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -132,11 +142,11 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
})
|
||||
);
|
||||
|
||||
const outOfStockCount = rows.filter((r) => !r.enough).length;
|
||||
const outOfStockCount = activeRows.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 medIds = activeRows.map((r) => r.medicationId).filter(Boolean);
|
||||
const allMeds =
|
||||
medIds.length > 0
|
||||
? await db
|
||||
@@ -156,7 +166,7 @@ ${t(dc.description, { from: fromDate, until: untilDate })}
|
||||
|
||||
${summaryText}
|
||||
|
||||
${rows
|
||||
${activeRows
|
||||
.map((r) => {
|
||||
const isBottle = r.packageType === "bottle";
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
@@ -191,7 +201,7 @@ ${getFooterPlain(language)}`;
|
||||
if (smtpHost && smtpUser) {
|
||||
// Build HTML table with horizontal scroll for mobile
|
||||
// Escape/coerce all user-provided values to prevent XSS
|
||||
const tableRows = rows
|
||||
const tableRows = activeRows
|
||||
.map((row) => {
|
||||
const safeName = escapeHtml(row.medicationName);
|
||||
const safePlannerUsage = Number(row.plannerUsage) || 0;
|
||||
@@ -312,7 +322,7 @@ ${getFooterPlain(language)}`;
|
||||
// Send push notification if enabled
|
||||
if (notificationSettings.shoutrrrEnabled && notificationSettings.shoutrrrUrl) {
|
||||
const pushTitle = t(dc.subject, { from: fromDate, until: untilDate });
|
||||
const pushMessage = `${summaryText}\n\n${rows
|
||||
const pushMessage = `${summaryText}\n\n${activeRows
|
||||
.map((r) => {
|
||||
const usage = `${r.plannerUsage} ${tr.common.pills}`;
|
||||
const status = r.enough ? dc.statusEnough : dc.statusEmpty;
|
||||
@@ -360,6 +370,16 @@ ${getFooterPlain(language)}`;
|
||||
|
||||
// Load user settings
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredLowStock.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const notificationSettings = {
|
||||
emailEnabled: userSettings.emailEnabled,
|
||||
@@ -374,9 +394,9 @@ ${getFooterPlain(language)}`;
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
// Separate into 3 categories: empty, critical, and low stock
|
||||
const emptyMeds = lowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = lowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
const emptyMeds = filteredLowStock.filter((r) => r.medsLeft <= 0);
|
||||
const criticalMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical !== false);
|
||||
const lowStockMeds = filteredLowStock.filter((r) => r.medsLeft > 0 && r.isCritical === false);
|
||||
|
||||
// Build shared notification content (method-agnostic)
|
||||
const titleParts: string[] = [];
|
||||
@@ -504,7 +524,7 @@ ${getFooterPlain(language)}`;
|
||||
</tr>`;
|
||||
};
|
||||
|
||||
const tableRows = lowStock.map(buildTableRow).join("");
|
||||
const tableRows = filteredLowStock.map(buildTableRow).join("");
|
||||
|
||||
const html = `
|
||||
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
|
||||
@@ -587,8 +607,7 @@ ${getFooterPlain(language)}`;
|
||||
updateReminderSentTime("stock", channel);
|
||||
|
||||
// Also update user settings in database so frontend can display the info
|
||||
const firstMed = lowStock[0];
|
||||
const medNames = lowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredLowStock.map((m: { name: string }) => m.name).join(", ");
|
||||
await updateUserReminderSentTime(userId, "stock", channel, medNames);
|
||||
}
|
||||
|
||||
@@ -618,14 +637,24 @@ ${getFooterPlain(language)}`;
|
||||
}
|
||||
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
.select({ name: medications.name })
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
|
||||
const activeMedNames = new Set(activeMeds.map((med) => med.name));
|
||||
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
|
||||
if (filteredPrescriptionLow.length === 0) {
|
||||
return reply.status(400).send({ error: "No active medications to notify" });
|
||||
}
|
||||
|
||||
const userSettings = await loadUserSettings(userId);
|
||||
const language = (userSettings.language as Language) || "en";
|
||||
const tr = getTranslations(language);
|
||||
|
||||
const emptyRx = prescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = prescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
const emptyRx = filteredPrescriptionLow.filter((item) => item.remainingRefills <= 0);
|
||||
const lowRx = filteredPrescriptionLow.filter((item) => item.remainingRefills > 0);
|
||||
|
||||
const lines = prescriptionLow.map((item) => {
|
||||
const lines = filteredPrescriptionLow.map((item) => {
|
||||
const expirySuffix = item.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: item.expiryDate }) : "";
|
||||
if (item.remainingRefills <= 0) {
|
||||
return `- ${t(tr.prescriptionReminder.lineEmpty, {
|
||||
@@ -640,7 +669,7 @@ ${getFooterPlain(language)}`;
|
||||
})}`;
|
||||
});
|
||||
|
||||
const medNames = prescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
const medNames = filteredPrescriptionLow.map((m: { name: string }) => m.name).join(", ");
|
||||
|
||||
const results: { email?: boolean; push?: boolean; errors: string[] } = { errors: [] };
|
||||
|
||||
@@ -665,9 +694,9 @@ ${getFooterPlain(language)}`;
|
||||
});
|
||||
|
||||
const subject =
|
||||
prescriptionLow.length === 1
|
||||
filteredPrescriptionLow.length === 1
|
||||
? tr.prescriptionReminder.subjectSingle
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: prescriptionLow.length });
|
||||
: t(tr.prescriptionReminder.subjectMultiple, { count: filteredPrescriptionLow.length });
|
||||
|
||||
const bodyText =
|
||||
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow;
|
||||
@@ -680,7 +709,7 @@ ${getFooterPlain(language)}`;
|
||||
? tr.prescriptionReminder.alertLowSingle
|
||||
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
|
||||
|
||||
const tableRows = prescriptionLow
|
||||
const tableRows = filteredPrescriptionLow
|
||||
.map((item) => {
|
||||
const isEmpty = item.remainingRefills <= 0;
|
||||
const safeName = escapeHtml(item.name);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import nodemailer from "nodemailer";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/db-utils.js";
|
||||
@@ -144,7 +144,11 @@ async function getMedicationsNeedingReminder(
|
||||
lowStockDays: number,
|
||||
language: Language
|
||||
): Promise<LowStockItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
const lowStock: LowStockItem[] = [];
|
||||
|
||||
@@ -176,7 +180,11 @@ async function getMedicationsNeedingReminder(
|
||||
}
|
||||
|
||||
async function getMedicationsNeedingPrescriptionReminder(userId: number): Promise<PrescriptionReminderItem[]> {
|
||||
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)))
|
||||
.orderBy(medications.id);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
|
||||
@@ -99,6 +99,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
|
||||
@@ -94,6 +94,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
|
||||
@@ -111,6 +111,9 @@ async function createSchema(client: Client) {
|
||||
expiry_date text,
|
||||
notes text,
|
||||
intake_reminders_enabled integer NOT NULL DEFAULT 0,
|
||||
medication_start_date text NOT NULL DEFAULT '',
|
||||
is_obsolete integer NOT NULL DEFAULT 0,
|
||||
obsolete_at integer,
|
||||
prescription_enabled integer NOT NULL DEFAULT 0,
|
||||
prescription_authorized_refills integer,
|
||||
prescription_remaining_refills integer,
|
||||
@@ -168,6 +171,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM medications");
|
||||
await client.execute("DELETE FROM user_settings");
|
||||
await client.execute("DELETE FROM users");
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
@@ -188,6 +192,18 @@ describe("Planner Routes", () => {
|
||||
"INSERT INTO users (id, username, auth_provider) VALUES (999999999, '__anonymous__', 'anonymous')"
|
||||
);
|
||||
|
||||
// Insert test medications so active-medication filters pass
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (1, 999999999, 'Aspirin', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (id, user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (2, 999999999, 'Ibuprofen', '["Daniel"]', '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]')`,
|
||||
args: [],
|
||||
});
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(plannerRoutes);
|
||||
await app.ready();
|
||||
|
||||
+8
-1
@@ -2,7 +2,14 @@
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.12/schema.json",
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"files": {
|
||||
"includes": ["backend/src/**/*.ts", "frontend/src/**/*.ts", "frontend/src/**/*.tsx", "frontend/src/**/*.css", "frontend/e2e/**/*.ts", "frontend/playwright.config.ts"]
|
||||
"includes": [
|
||||
"backend/src/**/*.ts",
|
||||
"frontend/src/**/*.ts",
|
||||
"frontend/src/**/*.tsx",
|
||||
"frontend/src/**/*.css",
|
||||
"frontend/e2e/**/*.ts",
|
||||
"frontend/playwright.config.ts"
|
||||
]
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -454,6 +454,11 @@ function AppContent() {
|
||||
coverage={coverage}
|
||||
settings={stockThresholds}
|
||||
onClose={closeUserFilter}
|
||||
onClearUser={() => {
|
||||
setSelectedUser(null);
|
||||
// Replace the userFilter history entry so it doesn't remain on the stack
|
||||
window.history.replaceState(null, "");
|
||||
}}
|
||||
onOpenMedDetail={openMedDetail}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// ConfirmModal Component - Simple confirmation dialog
|
||||
// =============================================================================
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
|
||||
export interface ConfirmModalProps {
|
||||
title: string;
|
||||
@@ -27,6 +27,17 @@ export function ConfirmModal({
|
||||
confirmVariant = "primary",
|
||||
overlayClassName,
|
||||
}: ConfirmModalProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onCancel();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div className={`modal-overlay${overlayClassName ? ` ${overlayClassName}` : ""}`} onClick={onCancel}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{ maxWidth: "450px" }}>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* DateInput - Custom date input that displays dates in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted date string on top of a native <input type="date">,
|
||||
* so the browser calendar popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDate, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateInput({ value, placeholder, className, ...rest }: DateInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
const displayValue = value ? formatDate(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="date" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* DateTimeInput - Custom datetime input that displays date+time in the regional locale format.
|
||||
*
|
||||
* Overlays a formatted datetime string on top of a native <input type="datetime-local">,
|
||||
* so the browser datetime popup still works but the displayed text
|
||||
* uses our locale-aware formatting (e.g., 14.02.2026, 20:30 for Germany).
|
||||
*/
|
||||
import { type InputHTMLAttributes, useCallback, useRef } from "react";
|
||||
import { formatDateTime, getNumericLocale } from "../utils/formatters";
|
||||
|
||||
interface DateTimeInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function DateTimeInput({ value, placeholder, className, ...rest }: DateTimeInputProps) {
|
||||
const locale = getNumericLocale();
|
||||
// datetime-local value is "YYYY-MM-DDTHH:MM" — formatDateTime handles this format
|
||||
const displayValue = value ? formatDateTime(value, locale) : "";
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
try {
|
||||
inputRef.current?.showPicker();
|
||||
} catch {
|
||||
// showPicker() may throw in some browsers — fallback to focus
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`date-input-wrapper ${className ?? ""}`} onClick={handleClick}>
|
||||
<span className="date-input-display" aria-hidden="true">
|
||||
{displayValue || placeholder || ""}
|
||||
</span>
|
||||
<input ref={inputRef} type="datetime-local" className="date-input-native" value={value} {...rest} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface LightboxProps {
|
||||
|
||||
export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
function handleOverlayClick(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
@@ -19,10 +20,12 @@ export function Lightbox({ src, alt, onClose }: LightboxProps) {
|
||||
|
||||
return (
|
||||
<div className="lightbox-overlay" onClick={handleOverlayClick}>
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
<div className="lightbox-container">
|
||||
<button className="lightbox-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<img src={src} alt={alt} className="lightbox-image" onClick={(e) => e.stopPropagation()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,7 +181,16 @@ export function MedDetailModal({
|
||||
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
|
||||
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
|
||||
<span className="med-taken-by">
|
||||
{t("modal.for")} {selectedMed.takenBy.join(", ")}
|
||||
{t("modal.for")}{" "}
|
||||
{selectedMed.takenBy.map((person, index) => (
|
||||
<span key={person}>
|
||||
{index > 0 && ", "}
|
||||
{person}
|
||||
{selectedMed.intakes?.some(
|
||||
(intake) => intake.takenBy === person && intake.intakeRemindersEnabled
|
||||
) && <span className="taken-by-badge">🔔</span>}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -287,7 +296,7 @@ export function MedDetailModal({
|
||||
{selectedMed.prescriptionEnabled && (
|
||||
<div className="med-detail-section">
|
||||
<h3>{t("form.sections.prescription")}</h3>
|
||||
<div className="med-detail-grid">
|
||||
<div className="med-detail-grid prescription-detail-grid">
|
||||
<div className="med-detail-item">
|
||||
<span className="med-detail-label">{t("prescription.authorizedRefills")}</span>
|
||||
<span className="med-detail-value">{selectedMed.prescriptionAuthorizedRefills ?? "—"}</span>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* MobileEditModal - Full-screen edit form for medications (mobile-optimized)
|
||||
* Handles new medication creation and editing existing medications
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DoseUnit, FieldErrors, FormBlister, FormIntake, FormState, Medication } from "../types";
|
||||
import { DOSE_UNITS } from "../types";
|
||||
import { deriveTotal } from "../utils";
|
||||
import { DateInput } from "./DateInput";
|
||||
|
||||
// Field limits for validation
|
||||
const FIELD_LIMITS = {
|
||||
@@ -25,6 +27,8 @@ export interface MobileEditModalProps {
|
||||
formSaved: boolean;
|
||||
formChanged: boolean;
|
||||
hasValidationErrors: boolean;
|
||||
dateConsistencyError: string | null;
|
||||
readOnlyMode: boolean;
|
||||
// TakenBy tag input
|
||||
takenByInput: string;
|
||||
onTakenByInputChange: (value: string) => void;
|
||||
@@ -84,6 +88,8 @@ export function MobileEditModal({
|
||||
formSaved,
|
||||
formChanged,
|
||||
hasValidationErrors,
|
||||
dateConsistencyError,
|
||||
readOnlyMode,
|
||||
takenByInput,
|
||||
onTakenByInputChange,
|
||||
existingPeople,
|
||||
@@ -114,6 +120,18 @@ export function MobileEditModal({
|
||||
}: MobileEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [show, onClose]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const currentMed = editingId ? meds.find((m) => m.id === editingId) : null;
|
||||
@@ -121,14 +139,11 @@ export function MobileEditModal({
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content edit-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<div className="edit-modal-header">
|
||||
<button type="button" className="ghost small" onClick={onClose}>
|
||||
<button type="button" className="ghost small btn-nav" onClick={onClose}>
|
||||
← {t("common.back")}
|
||||
</button>
|
||||
<h2>{editingId ? t("form.editEntry") : t("form.newEntry")}</h2>
|
||||
<h2>{editingId ? (readOnlyMode ? t("form.viewEntry") : t("form.editEntry")) : t("form.newEntry")}</h2>
|
||||
</div>
|
||||
<form
|
||||
className="form-grid mobile-edit-form"
|
||||
@@ -144,458 +159,473 @@ export function MobileEditModal({
|
||||
onSaveMedication(e);
|
||||
}}
|
||||
>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={`full ${fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<fieldset className="readonly-fieldset" disabled={readOnlyMode}>
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.general")}</h4>
|
||||
<label className={`full ${!readOnlyMode && fieldErrors.name ? "has-error" : ""}`}>
|
||||
{t("form.commercialName")}
|
||||
<input
|
||||
value={takenByInput}
|
||||
onChange={(e) => onTakenByInputChange(e.target.value)}
|
||||
onKeyDown={onTakenByKeyDown}
|
||||
onBlur={() => {
|
||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={
|
||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
||||
}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions-modal"
|
||||
value={form.name}
|
||||
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
|
||||
placeholder={t("form.placeholders.commercial")}
|
||||
maxLength={FIELD_LIMITS.name.max}
|
||||
required={!readOnlyMode}
|
||||
/>
|
||||
<datalist id="takenby-suggestions-modal">
|
||||
{existingPeople
|
||||
.filter((p) => !form.takenBy.includes(p))
|
||||
.map((person) => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
|
||||
{t("form.notes")}
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
|
||||
placeholder={t("form.placeholders.notes")}
|
||||
rows={2}
|
||||
maxLength={FIELD_LIMITS.notes.max}
|
||||
className="auto-resize"
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
{form.notes.length > 0 && (
|
||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||
</span>
|
||||
)}
|
||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||
<label className="full">
|
||||
{t("prescription.enabled")}
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.prescriptionEnabled}
|
||||
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
{!readOnlyMode && fieldErrors.name && <span className="field-error">{fieldErrors.name}</span>}
|
||||
</label>
|
||||
</label>
|
||||
{form.prescriptionEnabled && (
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
|
||||
{t("form.genericName")}
|
||||
<input
|
||||
value={form.genericName}
|
||||
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
|
||||
placeholder={t("form.placeholders.generic")}
|
||||
maxLength={FIELD_LIMITS.genericName.max}
|
||||
/>
|
||||
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.medicationStartDate")}
|
||||
<DateInput
|
||||
value={form.medicationStartDate}
|
||||
onChange={(e) => onHandleValueChange("medicationStartDate", e.target.value)}
|
||||
/>
|
||||
{!readOnlyMode && dateConsistencyError && <span className="field-error">{dateConsistencyError}</span>}
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.takenBy ? "has-error" : ""}`}>
|
||||
{t("form.takenBy")}
|
||||
<div className="tag-input-container">
|
||||
{form.takenBy.map((person) => (
|
||||
<span key={person} className="tag">
|
||||
{person}
|
||||
<button type="button" className="tag-remove" onClick={() => onRemoveTakenByPerson(person)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
value={takenByInput}
|
||||
onChange={(e) => onTakenByInputChange(e.target.value)}
|
||||
onKeyDown={onTakenByKeyDown}
|
||||
onBlur={() => {
|
||||
if (takenByInput.trim()) onAddTakenByPerson(takenByInput);
|
||||
}}
|
||||
placeholder={
|
||||
form.takenBy.length === 0 ? t("form.placeholders.takenBy") : t("form.placeholders.addPerson")
|
||||
}
|
||||
maxLength={FIELD_LIMITS.takenBy.max}
|
||||
list="takenby-suggestions-modal"
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.expiryDate")}
|
||||
<input
|
||||
type="date"
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<datalist id="takenby-suggestions-modal">
|
||||
{existingPeople
|
||||
.filter((p) => !form.takenBy.includes(p))
|
||||
.map((person) => (
|
||||
<option key={person} value={person} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
{fieldErrors.takenBy && <span className="field-error">{fieldErrors.takenBy}</span>}
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.packageType")}
|
||||
<select
|
||||
className="package-type-select"
|
||||
value={form.packageType}
|
||||
onChange={(e) => onHandleValueChange("packageType", e.target.value)}
|
||||
>
|
||||
<option value="blister">{t("form.packageTypeBlister")}</option>
|
||||
<option value="bottle">{t("form.packageTypeBottle")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="full form-category refill-section">
|
||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
||||
{editingId ? (
|
||||
<>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="full">
|
||||
{t("refill.pillsToAdd")}
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.stock")}</h4>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("form.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
value={form.packCount}
|
||||
onChange={(e) => onHandleValueChange("packCount", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="refill-submit-row full">
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(editingId)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(() => {
|
||||
const totalRefill =
|
||||
form.packageType === "blister"
|
||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
||||
refillLoose
|
||||
: refillLoose;
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{form.prescriptionEnabled && (
|
||||
<div className="refill-prescription-row full">
|
||||
<label className="refill-prescription-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePrescriptionRefill}
|
||||
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
|
||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
||||
/>
|
||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
||||
</label>
|
||||
<span className="refill-remaining-badge">
|
||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="refill-unavailable">{t("refill.saveFirst", "Save medication first to enable refill")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingId && (
|
||||
<div className="full form-category image-section">
|
||||
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
||||
{currentMed?.imageUrl ? (
|
||||
<div className="image-preview">
|
||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
<label>
|
||||
{t("form.blistersPerPack")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.blistersPerPack}
|
||||
onChange={(e) => onHandleValueChange("blistersPerPack", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.pillsPerBlister")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.pillsPerBlister}
|
||||
onChange={(e) => onHandleValueChange("pillsPerBlister", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
/>
|
||||
<>
|
||||
<label>
|
||||
{t("form.totalCapacity")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.totalPills}
|
||||
onChange={(e) => onHandleValueChange("totalPills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("form.currentPills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.looseTablets}
|
||||
onChange={(e) => onHandleValueChange("looseTablets", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="full form-category intake-section">
|
||||
<div className="form-category-header">
|
||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost add-blister"
|
||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
</div>
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<div className="full">
|
||||
<p className="sub">
|
||||
<strong>{t("form.total")}:</strong> {deriveTotalFromForm(form)}{" "}
|
||||
{deriveTotalFromForm(form) === 1 ? t("common.pill") : t("common.pills")}
|
||||
</p>
|
||||
</div>
|
||||
<label className="full">
|
||||
{t("form.pillWeight")} ({form.doseUnit})
|
||||
<div className="dose-input-group">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
value={form.pillWeightMg}
|
||||
onChange={(e) => onFormChange({ ...form, pillWeightMg: e.target.value })}
|
||||
placeholder={t("form.placeholders.weight")}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={intake.startDate}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
|
||||
{form.takenBy.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||
<span className="legend-hint">🔔</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
<select
|
||||
value={form.doseUnit}
|
||||
onChange={(e) => onFormChange({ ...form, doseUnit: e.target.value as DoseUnit })}
|
||||
className="dose-unit-select"
|
||||
>
|
||||
{DOSE_UNITS.map((unit) => (
|
||||
<option key={unit.value} value={unit.value}>
|
||||
{unit.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{form.intakes.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</label>
|
||||
<label className="full">
|
||||
{t("form.expiryDate")}
|
||||
<DateInput
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => onFormChange({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className={`full ${fieldErrors.notes ? "has-error" : ""}`}>
|
||||
{t("form.notes")}
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => onFormChange({ ...form, notes: e.target.value })}
|
||||
placeholder={t("form.placeholders.notes")}
|
||||
rows={2}
|
||||
maxLength={FIELD_LIMITS.notes.max}
|
||||
className="auto-resize"
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
{form.notes.length > 0 && (
|
||||
<span className={`char-count ${form.notes.length > FIELD_LIMITS.notes.max * 0.9 ? "warning" : ""}`}>
|
||||
{t("common.validation.tooLong", { current: form.notes.length, max: FIELD_LIMITS.notes.max })}
|
||||
</span>
|
||||
)}
|
||||
{fieldErrors.notes && <span className="field-error">{fieldErrors.notes}</span>}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="full form-category">
|
||||
<h4 className="form-category-title">{t("form.sections.prescription")}</h4>
|
||||
<label className="full">
|
||||
{t("prescription.enabled")}
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.prescriptionEnabled}
|
||||
onChange={(e) => onHandleValueChange("prescriptionEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</label>
|
||||
{form.prescriptionEnabled && (
|
||||
<>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.authorizedRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionAuthorizedRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionAuthorizedRefills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.remainingRefills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionRemainingRefills}
|
||||
onChange={(e) => onHandleValueChange("prescriptionRemainingRefills", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.lowThreshold")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={form.prescriptionLowRefillThreshold}
|
||||
onChange={(e) => onHandleValueChange("prescriptionLowRefillThreshold", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="prescription-field">
|
||||
{t("prescription.expiryDate")}
|
||||
<DateInput
|
||||
value={form.prescriptionExpiryDate}
|
||||
onChange={(e) => onHandleValueChange("prescriptionExpiryDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!readOnlyMode && (
|
||||
<div className="full form-category refill-section">
|
||||
<h4 className="form-category-title">{t("refill.title")}</h4>
|
||||
{editingId ? (
|
||||
<>
|
||||
{form.packageType === "blister" ? (
|
||||
<>
|
||||
<label>
|
||||
{t("refill.packs")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillPacks}
|
||||
onChange={(e) => onRefillPacksChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("refill.loosePills")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
) : (
|
||||
<label className="full">
|
||||
{t("refill.pillsToAdd")}
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={refillLoose}
|
||||
onChange={(e) => onRefillLooseChange(parseInt(e.target.value, 10) || 0)}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<div className="refill-submit-row full">
|
||||
<button
|
||||
type="button"
|
||||
className="success"
|
||||
onClick={() => onSubmitRefill(editingId)}
|
||||
disabled={(refillPacks < 1 && refillLoose < 1) || refillSaving}
|
||||
>
|
||||
{refillSaving ? t("common.saving") : t("refill.button")}
|
||||
</button>
|
||||
{(() => {
|
||||
const totalRefill =
|
||||
form.packageType === "blister"
|
||||
? refillPacks * Number(form.blistersPerPack || 0) * Number(form.pillsPerBlister || 1) +
|
||||
refillLoose
|
||||
: refillLoose;
|
||||
return totalRefill > 0 ? (
|
||||
<span className="refill-preview">
|
||||
+{totalRefill} {totalRefill === 1 ? t("common.pill") : t("common.pills")}
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
{form.prescriptionEnabled && (
|
||||
<div className="refill-prescription-row full">
|
||||
<label className="refill-prescription-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={usePrescriptionRefill}
|
||||
onChange={(e) => onUsePrescriptionRefillChange(e.target.checked)}
|
||||
disabled={(Number(form.prescriptionRemainingRefills) || 0) <= 0}
|
||||
/>
|
||||
<span className="refill-prescription-label-text">{t("prescription.useForRefill")}</span>
|
||||
</label>
|
||||
<span className="refill-remaining-badge">
|
||||
{t("prescription.remainingRefills")}: {Number(form.prescriptionRemainingRefills) || 0}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="refill-unavailable">
|
||||
{t("refill.saveFirst", "Save medication first to enable refill")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingId && (
|
||||
<div className="full form-category image-section">
|
||||
<h4 className="form-category-title">{t("form.medicationImage")}</h4>
|
||||
{currentMed?.imageUrl ? (
|
||||
<div className="image-preview">
|
||||
<img src={`/api/images/${currentMed.imageUrl}`} alt={currentMed.name} />
|
||||
<button type="button" className="danger" onClick={() => onDeleteMedImage(editingId)}>
|
||||
{t("form.removeImage")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && onUploadMedImage(editingId, e.target.files[0])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="full form-category intake-section">
|
||||
<div className="form-category-header">
|
||||
<h4 className="form-category-title">{t("form.blisters.title")}</h4>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost add-blister"
|
||||
onClick={() => onAddIntake(form.takenBy.length === 1 ? form.takenBy[0] : undefined)}
|
||||
>
|
||||
+ {t("form.blisters.addIntake")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.intakes.map((intake, idx) => (
|
||||
<div key={idx} className="blister-row">
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.usage")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
pattern="[0-9]*\.?[0-9]*"
|
||||
value={intake.usage}
|
||||
onChange={(e) => onSetIntakeValue(idx, "usage", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact">
|
||||
<span>{t("form.blisters.everyDays")}</span>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={intake.every}
|
||||
onChange={(e) => onSetIntakeValue(idx, "every", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.startDate")}</span>
|
||||
<DateInput
|
||||
value={intake.startDate}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startDate", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="compact time-label">
|
||||
<span>{t("form.blisters.startTime")}</span>
|
||||
<input
|
||||
type="time"
|
||||
value={intake.startTime}
|
||||
onChange={(e) => onSetIntakeValue(idx, "startTime", e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{form.takenBy.length === 0 ? null : (
|
||||
<label className="compact full-row">
|
||||
<span>{t("form.blisters.takenByIntake")}</span>
|
||||
<select value={intake.takenBy} onChange={(e) => onSetIntakeValue(idx, "takenBy", e.target.value)}>
|
||||
{form.takenBy.map((person) => (
|
||||
<option key={person} value={person}>
|
||||
{person}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<div className="remind-toggle-row" title={t("form.blisters.remindTooltip")}>
|
||||
<span className="legend-hint">🔔</span>
|
||||
<label className="toggle-switch small">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={intake.intakeRemindersEnabled}
|
||||
onChange={(e) => onSetIntakeValue(idx, "intakeRemindersEnabled", e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
{!readOnlyMode && form.intakes.length > 1 && (
|
||||
<button type="button" className="danger remove-blister-btn" onClick={() => onRemoveIntake(idx)}>
|
||||
{t("common.remove")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || hasValidationErrors || (!formChanged && (formSaved || !!editingId))}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
{readOnlyMode ? t("common.close") : t("common.cancel")}
|
||||
</button>
|
||||
{!readOnlyMode && (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || (!formChanged && (formSaved || !!editingId))}
|
||||
className={hasValidationErrors || dateConsistencyError ? "has-validation-error" : ""}
|
||||
>
|
||||
{formSaved && !formChanged ? t("common.saved") : t("common.save")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MedicationAvatar } from "../components";
|
||||
import type { Coverage, Medication, StockThresholds } from "../types";
|
||||
import { getMedTotal, getPackageSize } from "../types";
|
||||
import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
@@ -15,6 +16,7 @@ export interface UserFilterModalProps {
|
||||
coverage: { all: Coverage[] };
|
||||
settings: StockThresholds;
|
||||
onClose: () => void;
|
||||
onClearUser: () => void;
|
||||
onOpenMedDetail: (med: Medication) => void;
|
||||
}
|
||||
|
||||
@@ -24,13 +26,14 @@ export function UserFilterModal({
|
||||
coverage,
|
||||
settings,
|
||||
onClose,
|
||||
onClearUser,
|
||||
onOpenMedDetail,
|
||||
}: UserFilterModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!selectedUser) return null;
|
||||
|
||||
const userMeds = meds.filter((m) => (m.takenBy || []).includes(selectedUser));
|
||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
@@ -47,15 +50,29 @@ export function UserFilterModal({
|
||||
<div className="user-meds-list">
|
||||
{userMeds.map((med) => {
|
||||
const medCoverage = coverage.all.find((c) => c.name === med.name);
|
||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
|
||||
const status = medCoverage
|
||||
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
|
||||
: getStockStatus(null, getMedTotal(med), settings);
|
||||
const packageSize = getPackageSize(med);
|
||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(getMedTotal(med));
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = (
|
||||
med.intakes ||
|
||||
med.blisters.map((b) => ({
|
||||
...b,
|
||||
takenBy: null as string | null,
|
||||
intakeRemindersEnabled: false,
|
||||
}))
|
||||
).filter((intake) => intake.takenBy === null || intake.takenBy === selectedUser);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={med.id}
|
||||
className="user-med-item clickable"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onClearUser();
|
||||
onOpenMedDetail(med);
|
||||
}}
|
||||
>
|
||||
@@ -63,6 +80,25 @@ export function UserFilterModal({
|
||||
<div className="user-med-info">
|
||||
<span className="user-med-name">{med.name}</span>
|
||||
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
|
||||
{personIntakes.length > 0 && (
|
||||
<div className="user-med-intakes">
|
||||
{personIntakes.map((intake, idx) => {
|
||||
const timeStr = new Date(intake.start).toLocaleTimeString(getSystemLocale(i18n.language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return (
|
||||
<span key={idx} className="user-med-intake-item">
|
||||
{intake.usage} {intake.usage !== 1 ? t("common.pills") : t("common.pill")}
|
||||
{med.pillWeightMg != null &&
|
||||
` (${intake.usage * med.pillWeightMg} ${med.doseUnit ?? "mg"})`}{" "}
|
||||
{t("form.blisters.every")} {intake.every}{" "}
|
||||
{intake.every !== 1 ? t("common.days") : t("common.day")} {t("modal.at")} {timeStr}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="user-med-stats">
|
||||
<span className="user-med-pills">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
export { default as AboutModal } from "./AboutModal";
|
||||
export type { ConfirmModalProps } from "./ConfirmModal";
|
||||
export { ConfirmModal } from "./ConfirmModal";
|
||||
export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
export type { LightboxProps } from "./Lightbox";
|
||||
|
||||
|
||||
@@ -270,15 +270,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Computed values - combine app language with timezone region for locale
|
||||
const systemLocale = getSystemLocale(i18n.language);
|
||||
const schedule = useMemo(
|
||||
() => buildSchedulePreview(medications.meds, systemLocale, true),
|
||||
[medications.meds, systemLocale]
|
||||
);
|
||||
const activeMeds = useMemo(() => medications.meds.filter((m) => !m.isObsolete), [medications.meds]);
|
||||
const schedule = useMemo(() => buildSchedulePreview(activeMeds, systemLocale, true), [activeMeds, systemLocale]);
|
||||
|
||||
const coverage = useMemo(
|
||||
() =>
|
||||
calculateCoverage(
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -287,7 +285,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
doses.takenDoseTimestamps
|
||||
),
|
||||
[
|
||||
medications.meds,
|
||||
activeMeds,
|
||||
schedule.events,
|
||||
systemLocale,
|
||||
settingsHook.settings.reminderDaysBefore,
|
||||
@@ -430,8 +428,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [groupedSchedule, scheduleDays]);
|
||||
|
||||
const missedPastDoseIds = useMemo(
|
||||
() => computeMissedPastDoseIds(pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, medications.meds, doses.takenDoses, doses.dismissedDoses]
|
||||
() => computeMissedPastDoseIds(pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses),
|
||||
[pastDays, activeMeds, doses.takenDoses, doses.dismissedDoses]
|
||||
);
|
||||
|
||||
// Modal helpers with browser history support
|
||||
@@ -486,8 +484,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Wrapper to pass meds to openShareDialog
|
||||
const openShareDialog = useCallback(() => {
|
||||
share.openShareDialog(medications.meds);
|
||||
}, [share, medications.meds]);
|
||||
share.openShareDialog(activeMeds);
|
||||
}, [share, activeMeds]);
|
||||
|
||||
// Get t function for translations
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -41,6 +41,7 @@ export const defaultForm = (): FormState => ({
|
||||
looseTablets: "0",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
prescriptionEnabled: false,
|
||||
@@ -230,6 +231,7 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
looseTablets: String(med.looseTablets),
|
||||
pillWeightMg: med.pillWeightMg ? String(med.pillWeightMg) : "",
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate ?? "",
|
||||
expiryDate: med.expiryDate ? med.expiryDate.slice(0, 10) : "",
|
||||
notes: med.notes ?? "",
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useMedications(): UseMedicationsReturn {
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications", { credentials: "include" })
|
||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medikamentenliste",
|
||||
"entries": "{{count}} Einträge",
|
||||
"entries_one": "{{count}} Eintrag",
|
||||
"entries_other": "{{count}} Einträge"
|
||||
"entries_other": "{{count}} Einträge",
|
||||
"markObsolete": "Als obsolet markieren",
|
||||
"reactivate": "Reaktivieren",
|
||||
"obsoleteTitle": "Obsolet ({{count}})",
|
||||
"obsoleteSince": "Beendet",
|
||||
"started": "Gestartet"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packungen",
|
||||
@@ -140,10 +145,15 @@
|
||||
"deleteModal": {
|
||||
"title": "Medikament löschen",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Medikament als obsolet markieren",
|
||||
"message": "Möchtest du \"{{name}}\" wirklich als obsolet markieren?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Medikament bearbeiten",
|
||||
"viewEntry": "Medikament ansehen",
|
||||
"newEntry": "Neues Medikament",
|
||||
"badge": "Packungen + lose Tabletten",
|
||||
"sections": {
|
||||
@@ -165,6 +175,7 @@
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillWeight": "Dosis pro Tablette",
|
||||
"total": "Gesamt (Tabletten)",
|
||||
"medicationStartDate": "Medikations-Startdatum",
|
||||
"expiryDate": "Ablaufdatum",
|
||||
"notes": "Notizen",
|
||||
"medicationImage": "Medikamentenbild",
|
||||
@@ -177,6 +188,9 @@
|
||||
"weight": "z.B. 240",
|
||||
"notes": "z.B. Mit Essen einnehmen, Alkohol vermeiden... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Das Medikations-Startdatum ({{medicationStartDate}}) darf nicht nach dem Einnahmedatum ({{intakeDate}}) liegen."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Einnahmeplan",
|
||||
"remind": "Erinnern",
|
||||
@@ -406,6 +420,7 @@
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansehen",
|
||||
"delete": "Löschen",
|
||||
"remove": "Entfernen",
|
||||
"reset": "Zurücksetzen",
|
||||
@@ -513,7 +528,7 @@
|
||||
"prescription": {
|
||||
"enabled": "Rezept verfolgen",
|
||||
"authorizedRefills": "Genehmigte Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Nachfüllungen",
|
||||
"remainingRefills": "Verbleibende Rezept-Nachfüllungen",
|
||||
"lowThreshold": "Schwelle für Rezept-Erinnerung",
|
||||
"expiryDate": "Rezeptablauf",
|
||||
"useForRefill": "Rezept-Nachfüllung verwenden"
|
||||
|
||||
@@ -125,7 +125,12 @@
|
||||
"title": "Medication list",
|
||||
"entries": "{{count}} entries",
|
||||
"entries_one": "{{count}} entry",
|
||||
"entries_other": "{{count}} entries"
|
||||
"entries_other": "{{count}} entries",
|
||||
"markObsolete": "Mark obsolete",
|
||||
"reactivate": "Reactivate",
|
||||
"obsoleteTitle": "Obsolete ({{count}})",
|
||||
"obsoleteSince": "Stopped",
|
||||
"started": "Started"
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packs",
|
||||
@@ -140,10 +145,15 @@
|
||||
"deleteModal": {
|
||||
"title": "Delete medication",
|
||||
"message": "Do you really want to delete \"{{name}}\"?"
|
||||
},
|
||||
"obsoleteModal": {
|
||||
"title": "Mark medication as obsolete",
|
||||
"message": "Do you really want to mark \"{{name}}\" as obsolete?"
|
||||
}
|
||||
},
|
||||
"form": {
|
||||
"editEntry": "Edit medication",
|
||||
"viewEntry": "View medication",
|
||||
"newEntry": "New medication",
|
||||
"badge": "Packs + loose pills",
|
||||
"sections": {
|
||||
@@ -165,6 +175,7 @@
|
||||
"loosePills": "Loose pills",
|
||||
"pillWeight": "Dose per pill",
|
||||
"total": "Total (pills)",
|
||||
"medicationStartDate": "Medication Start Date",
|
||||
"expiryDate": "Expiry Date",
|
||||
"notes": "Notes",
|
||||
"medicationImage": "Medication Image",
|
||||
@@ -177,6 +188,9 @@
|
||||
"weight": "e.g. 240",
|
||||
"notes": "e.g. Take with food, avoid alcohol... (optional)"
|
||||
},
|
||||
"validation": {
|
||||
"startDateAfterIntake": "Medication start date ({{medicationStartDate}}) cannot be after intake date ({{intakeDate}})."
|
||||
},
|
||||
"blisters": {
|
||||
"title": "Intake schedule",
|
||||
"remind": "Remind",
|
||||
@@ -406,6 +420,7 @@
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
"remove": "Remove",
|
||||
"reset": "Reset",
|
||||
@@ -513,7 +528,7 @@
|
||||
"prescription": {
|
||||
"enabled": "Track prescription",
|
||||
"authorizedRefills": "Authorized refills",
|
||||
"remainingRefills": "Remaining refills",
|
||||
"remainingRefills": "Remaining prescription refills",
|
||||
"lowThreshold": "Low-refill reminder threshold",
|
||||
"expiryDate": "Prescription expiry",
|
||||
"useForRefill": "Use prescription refill"
|
||||
|
||||
@@ -607,42 +607,34 @@ export function DashboardPage() {
|
||||
<span data-label={t("table.name")} className="cell-with-avatar">
|
||||
<span className="med-name-line">
|
||||
<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 className="med-name-block-dash">
|
||||
<span className="med-name-text">{row.name}</span>
|
||||
{med?.takenBy && med.takenBy.length > 0 && (
|
||||
<span className="med-taken-by-line">
|
||||
{med.takenBy.map((person) => (
|
||||
<span
|
||||
key={person}
|
||||
className="taken-by-badge clickable"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUserFilter(person);
|
||||
}}
|
||||
>
|
||||
{person}
|
||||
{med.intakes?.some((i) => i.takenBy === person && i.intakeRemindersEnabled) && " 🔔"}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
))}
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{(() => {
|
||||
const hasIntakeReminders =
|
||||
med?.intakes?.some((i) => i.intakeRemindersEnabled) ?? med?.intakeRemindersEnabled;
|
||||
return (
|
||||
(hasIntakeReminders || med?.notes) && (
|
||||
<span className="med-icons">
|
||||
{hasIntakeReminders && (
|
||||
<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>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
{med?.notes && (
|
||||
<span className="med-icons">
|
||||
<span className="notes-icon info-tooltip" data-tooltip={t("tooltips.hasNotes")}>
|
||||
📝
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span data-label={t("table.stock")} className={textClass}>
|
||||
{med?.packageType === "bottle"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MedicationAvatar } from "../components";
|
||||
import { DateTimeInput, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import type { PlannerRow } from "../types";
|
||||
@@ -158,8 +158,7 @@ export function PlannerPage() {
|
||||
<form className="planner" onSubmit={runPlanner}>
|
||||
<label>
|
||||
{t("planner.from")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
<DateTimeInput
|
||||
step="60"
|
||||
value={range.start}
|
||||
onChange={(e) => setRange({ ...range, start: e.target.value })}
|
||||
@@ -167,12 +166,7 @@ export function PlannerPage() {
|
||||
</label>
|
||||
<label>
|
||||
{t("planner.until")}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step="60"
|
||||
value={range.end}
|
||||
onChange={(e) => setRange({ ...range, end: e.target.value })}
|
||||
/>
|
||||
<DateTimeInput step="60" value={range.end} onChange={(e) => setRange({ ...range, end: e.target.value })} />
|
||||
</label>
|
||||
<label className="planner-checkbox">
|
||||
<input
|
||||
|
||||
+472
-43
@@ -30,6 +30,13 @@
|
||||
--btn-primary-bg: var(--accent);
|
||||
--btn-primary-hover: #3d94ff;
|
||||
--btn-ghost-hover: rgba(255, 255, 255, 0.08);
|
||||
--btn-danger-text: #2f0a0a;
|
||||
--btn-success-text: #0a2b1f;
|
||||
--btn-obsolete-bg: linear-gradient(135deg, #f7d14a 0%, #f2b91a 100%);
|
||||
--btn-obsolete-hover: linear-gradient(135deg, #f9db72 0%, #f5c73c 100%);
|
||||
--btn-obsolete-text: #2b2205;
|
||||
--btn-obsolete-border: #f8e38a;
|
||||
--btn-obsolete-shadow: 0 6px 14px rgba(252, 211, 77, 0.28);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
@@ -60,6 +67,13 @@
|
||||
--btn-primary-bg: var(--accent);
|
||||
--btn-primary-hover: #1d4ed8;
|
||||
--btn-ghost-hover: rgba(0, 0, 0, 0.06);
|
||||
--btn-danger-text: #ffffff;
|
||||
--btn-success-text: #ffffff;
|
||||
--btn-obsolete-bg: linear-gradient(135deg, #f5b52c 0%, #f59e0b 100%);
|
||||
--btn-obsolete-hover: linear-gradient(135deg, #f8c85b 0%, #f7ad2d 100%);
|
||||
--btn-obsolete-text: #ffffff;
|
||||
--btn-obsolete-border: #d48806;
|
||||
--btn-obsolete-shadow: 0 6px 14px rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -90,6 +104,7 @@ body.modal-open {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 3rem;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -491,8 +506,10 @@ body.modal-open {
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
margin-bottom: 1rem;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -617,19 +634,128 @@ body.modal-open {
|
||||
.med-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.med-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.med-group {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: 12px;
|
||||
padding: 0.9rem;
|
||||
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
||||
}
|
||||
|
||||
.med-group-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.med-group-head-toggle {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-radius: 8px;
|
||||
padding: 0.1rem 0.25rem;
|
||||
margin: -0.1rem -0.25rem 0.8rem;
|
||||
}
|
||||
|
||||
.med-group-head-toggle:hover .med-group-title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.med-group-title {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.med-group-obsolete {
|
||||
border-color: var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.med-grid-obsolete {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.obsolete-row {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button {
|
||||
opacity: 0.72;
|
||||
filter: saturate(0.72);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.obsolete-row .med-actions button:hover {
|
||||
opacity: 0.9;
|
||||
filter: saturate(0.85);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.med-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Flatten nested boxes on mobile to reclaim horizontal space */
|
||||
.med-grid-wrapper > .card {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.med-grid-wrapper .card-head {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.med-group,
|
||||
.med-groups {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.med-group-head {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.med-row {
|
||||
padding: 0.65rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.blister-row-simple {
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.med-details {
|
||||
gap: 0.2rem 0.75rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
}
|
||||
|
||||
.med-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
display: grid;
|
||||
grid-template-rows: subgrid;
|
||||
grid-row: span 2;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-primary);
|
||||
padding: 1rem;
|
||||
border-radius: 10px;
|
||||
@@ -646,9 +772,8 @@ body.modal-open {
|
||||
}
|
||||
.med-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.med-info {
|
||||
flex: 1;
|
||||
@@ -661,7 +786,7 @@ body.modal-open {
|
||||
}
|
||||
.med-details {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: max-content max-content;
|
||||
gap: 0.25rem 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
@@ -690,8 +815,9 @@ body.modal-open {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
width: 100%;
|
||||
align-self: start;
|
||||
}
|
||||
.blister-row-simple {
|
||||
color: var(--text-muted);
|
||||
@@ -788,6 +914,8 @@ body.modal-open {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.med-actions button {
|
||||
padding: 0.5rem 0.9rem;
|
||||
@@ -800,6 +928,9 @@ body.modal-open {
|
||||
.med-actions {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.med-details {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
.blister-list {
|
||||
display: flex;
|
||||
@@ -931,7 +1062,7 @@ button.secondary:hover {
|
||||
/* Success button (Refill, etc.) */
|
||||
button.success {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
color: var(--btn-success-text);
|
||||
border: none;
|
||||
}
|
||||
button.success:hover {
|
||||
@@ -982,10 +1113,39 @@ button.ghost:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
}
|
||||
|
||||
/* Navigation button (Back): neutral and low visual urgency */
|
||||
button.btn-nav {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-secondary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-nav:hover {
|
||||
background: var(--btn-ghost-hover);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Reversible status-change button (Mark obsolete): warning, not destructive */
|
||||
button.btn-obsolete {
|
||||
background: var(--btn-obsolete-bg);
|
||||
border: 1px solid var(--btn-obsolete-border);
|
||||
color: var(--btn-obsolete-text);
|
||||
box-shadow: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
button.btn-obsolete:hover {
|
||||
background: var(--btn-obsolete-hover);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
button.btn-obsolete:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Danger button (Delete, etc.) */
|
||||
button.danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
color: var(--btn-danger-text);
|
||||
border: none;
|
||||
}
|
||||
button.danger:hover {
|
||||
@@ -1212,6 +1372,70 @@ textarea.auto-resize {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.field-error.error-pulse {
|
||||
animation: error-pulse-anim 1.5s ease;
|
||||
}
|
||||
|
||||
@keyframes error-pulse-anim {
|
||||
0%,
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
15% {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
85% {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
|
||||
button.has-validation-error {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.readonly-fieldset {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Subtle read-only styling for disabled fieldset inputs */
|
||||
.readonly-fieldset:disabled input,
|
||||
.readonly-fieldset:disabled select,
|
||||
.readonly-fieldset:disabled textarea,
|
||||
.readonly-fieldset:disabled .date-input-wrapper {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
border-color: transparent;
|
||||
background: var(--bg-input);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .date-input-display {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .tag {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .static-value {
|
||||
opacity: 0.55;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled .tag-input-container {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.readonly-fieldset:disabled label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Dose input with unit selector */
|
||||
.dose-input-group {
|
||||
display: flex;
|
||||
@@ -2038,13 +2262,13 @@ textarea.auto-resize {
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
.table-row span {
|
||||
.table-row > span {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
.table-row span::before {
|
||||
.table-row > span::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
color: var(--accent-light);
|
||||
@@ -2055,44 +2279,44 @@ textarea.auto-resize {
|
||||
text-align: left;
|
||||
}
|
||||
/* First span (name cell) - centered horizontal layout */
|
||||
.table-row span:first-child {
|
||||
justify-content: center;
|
||||
.table-row > span:first-child {
|
||||
justify-content: flex-start;
|
||||
padding-bottom: 0.15rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.table-row span:first-child::before {
|
||||
.table-row > span:first-child::before {
|
||||
display: none; /* Hide "NAME" label on mobile */
|
||||
}
|
||||
/* Status chip in table row - left aligned */
|
||||
.table-row span.status-chip {
|
||||
.table-row > span.status-chip {
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.table-row span.status-chip::before {
|
||||
.table-row > span.status-chip::before {
|
||||
margin-right: 0;
|
||||
}
|
||||
/* Avatar + name layout - centered */
|
||||
/* Avatar + name layout - left aligned */
|
||||
.table-row .cell-with-avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.table-row .cell-with-avatar .med-name-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.table-row .cell-with-avatar .med-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Icons on separate line on mobile - centered under med name */
|
||||
/* Icons on separate line on mobile - left aligned */
|
||||
.table-row .cell-with-avatar .med-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2102,6 +2326,30 @@ textarea.auto-resize {
|
||||
.table-row .notes-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Prevent data-label ::before on nested spans inside name cell */
|
||||
.table-row .cell-with-avatar span::before {
|
||||
display: none;
|
||||
}
|
||||
.table-row .cell-with-avatar .taken-by-badge,
|
||||
.table-row .cell-with-avatar .med-name-text {
|
||||
display: inline !important;
|
||||
justify-content: initial;
|
||||
}
|
||||
.table-row .med-taken-by-line {
|
||||
display: flex !important;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.table-row .cell-with-avatar .taken-by-badge {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
margin-left: 0;
|
||||
}
|
||||
.table-4 .table-head,
|
||||
.table-4 .table-row,
|
||||
.table-5 .table-head,
|
||||
@@ -2116,7 +2364,17 @@ textarea.auto-resize {
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page {
|
||||
padding: 1rem 0.75rem 2rem;
|
||||
padding: 0.75rem 0.4rem 2rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 0.65rem;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.hero {
|
||||
@@ -3389,6 +3647,20 @@ textarea.auto-resize {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.med-avatar-clickable {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
}
|
||||
.med-avatar-clickable .med-avatar {
|
||||
transition:
|
||||
transform 0.15s,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.med-avatar-clickable:hover .med-avatar {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Table/Timeline cells with avatar */
|
||||
.cell-with-avatar {
|
||||
display: flex;
|
||||
@@ -3403,6 +3675,21 @@ textarea.auto-resize {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.med-taken-by-line {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.med-name-block-dash {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cell-with-avatar .med-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3444,12 +3731,26 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.med-name-block {
|
||||
min-width: 0;
|
||||
}
|
||||
.med-generic-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.image-preview button {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
@@ -3509,19 +3810,24 @@ textarea.auto-resize {
|
||||
}
|
||||
}
|
||||
|
||||
.modal-close,
|
||||
.lightbox-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: var(--btn-ghost-hover);
|
||||
border: 1px solid var(--border-secondary);
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -3738,6 +4044,19 @@ textarea.auto-resize {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-med-intakes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.user-med-intake-item {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-meds-empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -3828,6 +4147,16 @@ textarea.auto-resize {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-item {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(2.6em, auto) auto;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.prescription-detail-grid .med-detail-value {
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.med-detail-item {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.75rem;
|
||||
@@ -3961,19 +4290,19 @@ textarea.auto-resize {
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.lightbox-container {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.lightbox-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
min-height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -3981,11 +4310,11 @@ textarea.auto-resize {
|
||||
transition: background 150ms ease;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.lightbox-close:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
@@ -5645,6 +5974,37 @@ a.about-version-link:hover {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Desktop Edit Panel (two-column layout) ── */
|
||||
.edit-sidebar {
|
||||
display: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.med-grid-wrapper.desktop-edit-open {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(380px, 46%);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.med-grid-wrapper.desktop-edit-open .med-grid,
|
||||
.med-grid-wrapper.desktop-edit-open .med-grid-obsolete {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.edit-sidebar.open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-sidebar .card {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Desktop only - hide on mobile */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-only {
|
||||
@@ -5657,7 +6017,7 @@ a.about-version-link:hover {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.edit-modal-header {
|
||||
@@ -5687,8 +6047,9 @@ a.about-version-link:hover {
|
||||
|
||||
.mobile-edit-form .form-category {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
padding: 0.8rem;
|
||||
gap: 0.75rem 0.75rem;
|
||||
padding: 0.5rem;
|
||||
border-color: color-mix(in srgb, var(--border-primary) 50%, transparent);
|
||||
}
|
||||
|
||||
.mobile-edit-form .refill-prescription-row {
|
||||
@@ -5848,6 +6209,74 @@ a.about-version-link:hover {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Custom DateInput / DateTimeInput
|
||||
========================================== */
|
||||
.date-input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.date-input-display {
|
||||
position: absolute;
|
||||
left: 0.85rem;
|
||||
right: 2.5rem;
|
||||
pointer-events: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
font-family: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.date-input-native {
|
||||
color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure native text stays invisible on focus/selection */
|
||||
.date-input-native:focus,
|
||||
.date-input-native:active {
|
||||
color: transparent !important;
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
|
||||
.date-input-native::selection {
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.date-input-native::-webkit-datetime-edit {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Keep the calendar/clock picker icon visible and clickable */
|
||||
.date-input-native::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
filter: invert(0.8);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.date-input-native::-webkit-calendar-picker-indicator:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Light theme: don't invert icon */
|
||||
@media (prefers-color-scheme: light) {
|
||||
.date-input-native::-webkit-calendar-picker-indicator {
|
||||
filter: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.action-card {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,9 +14,16 @@ const defaultForm: FormState = {
|
||||
looseTablets: "0",
|
||||
totalPills: "",
|
||||
pillWeightMg: "",
|
||||
doseUnit: "mg",
|
||||
medicationStartDate: "",
|
||||
expiryDate: "",
|
||||
notes: "",
|
||||
intakeRemindersEnabled: false,
|
||||
prescriptionEnabled: false,
|
||||
prescriptionAuthorizedRefills: "",
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
blisters: [
|
||||
{
|
||||
usage: "1",
|
||||
@@ -47,6 +54,8 @@ const defaultProps = {
|
||||
formSaved: false,
|
||||
formChanged: false,
|
||||
hasValidationErrors: false,
|
||||
dateConsistencyError: null,
|
||||
readOnlyMode: false,
|
||||
takenByInput: "",
|
||||
onTakenByInputChange: vi.fn(),
|
||||
existingPeople: [],
|
||||
@@ -108,7 +117,7 @@ describe("MobileEditModal", () => {
|
||||
it("renders close button", () => {
|
||||
render(<MobileEditModal {...defaultProps} />);
|
||||
|
||||
const closeBtn = document.querySelector(".modal-close");
|
||||
const closeBtn = document.querySelector(".btn-nav");
|
||||
expect(closeBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -116,7 +125,7 @@ describe("MobileEditModal", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<MobileEditModal {...defaultProps} onClose={onClose} />);
|
||||
|
||||
const closeBtn = document.querySelector(".modal-close");
|
||||
const closeBtn = document.querySelector(".btn-nav");
|
||||
if (closeBtn) {
|
||||
fireEvent.click(closeBtn);
|
||||
}
|
||||
@@ -191,7 +200,7 @@ describe("MobileEditModal", () => {
|
||||
render(<MobileEditModal {...defaultProps} hasValidationErrors={true} />);
|
||||
|
||||
const saveBtn = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||||
expect(saveBtn).toBeDisabled();
|
||||
expect(saveBtn).toHaveClass("has-validation-error");
|
||||
});
|
||||
|
||||
it("renders add intake button", () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -63,6 +64,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -81,6 +83,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -100,6 +103,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -118,6 +122,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -136,6 +141,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -154,6 +160,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -175,6 +182,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -187,8 +195,9 @@ describe("UserFilterModal", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClose and onOpenMedDetail when medication clicked", () => {
|
||||
it("calls onClearUser and onOpenMedDetail when medication clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
const onClearUser = vi.fn();
|
||||
const onOpenMedDetail = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -198,6 +207,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={onClearUser}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -207,7 +217,7 @@ describe("UserFilterModal", () => {
|
||||
fireEvent.click(medItem);
|
||||
}
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onClearUser).toHaveBeenCalledTimes(1);
|
||||
expect(onOpenMedDetail).toHaveBeenCalledWith(mockMedication);
|
||||
});
|
||||
|
||||
@@ -222,6 +232,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -243,6 +254,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [mockCoverage] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
@@ -272,6 +284,7 @@ describe("UserFilterModal", () => {
|
||||
coverage={{ all: [] }}
|
||||
settings={defaultSettings}
|
||||
onClose={onClose}
|
||||
onClearUser={vi.fn()}
|
||||
onOpenMedDetail={onOpenMedDetail}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("useMedications", () => {
|
||||
expect(result.current.meds).toEqual(mockMeds);
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
});
|
||||
|
||||
it("handles API error gracefully", async () => {
|
||||
@@ -123,7 +123,7 @@ describe("useMedications", () => {
|
||||
});
|
||||
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications/5", { method: "DELETE", credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications", { credentials: "include" });
|
||||
expect(fetch).toHaveBeenCalledWith("/api/medications?includeObsolete=true", { credentials: "include" });
|
||||
expect(mockResetForm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -607,10 +607,7 @@ describe("DashboardPage with medications", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// Aspirin has intakeRemindersEnabled and notes
|
||||
const reminderIcons = document.querySelectorAll(".reminder-icon");
|
||||
expect(reminderIcons.length).toBeGreaterThan(0);
|
||||
|
||||
// Aspirin has notes
|
||||
const notesIcons = document.querySelectorAll(".notes-icon");
|
||||
expect(notesIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -83,6 +83,8 @@ const createMockFormHook = (overrides = {}) => ({
|
||||
prescriptionRemainingRefills: "",
|
||||
prescriptionLowRefillThreshold: "1",
|
||||
prescriptionExpiryDate: "",
|
||||
medicationStartDate: "",
|
||||
doseUnit: "mg" as const,
|
||||
},
|
||||
setForm: vi.fn(),
|
||||
editingId: null,
|
||||
@@ -132,6 +134,10 @@ vi.mock("../../context", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/Auth", () => ({
|
||||
useAuth: () => ({ user: { id: 1, username: "testuser" }, isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
function renderPage() {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
@@ -156,7 +162,8 @@ describe("MedicationsPage", () => {
|
||||
it("renders list-first view with new button", () => {
|
||||
renderPage();
|
||||
expect(screen.getByText(/medications\.list\.title/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/form\.newEntry/i)).toBeInTheDocument();
|
||||
// Button text and form heading both contain "form.newEntry" in the DOM
|
||||
expect(screen.getAllByText(/form\.newEntry/i).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("opens form after clicking new button", () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ export type Medication = {
|
||||
lastStockCorrectionAt?: string | null;
|
||||
pillWeightMg?: number | null;
|
||||
doseUnit?: DoseUnit | null; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
medicationStartDate?: string | null;
|
||||
blisters: Blister[]; // Legacy array format
|
||||
intakes?: Intake[]; // New intake format with per-intake takenBy
|
||||
imageUrl?: string | null;
|
||||
@@ -58,6 +59,8 @@ export type Medication = {
|
||||
prescriptionLowRefillThreshold?: number;
|
||||
prescriptionExpiryDate?: string | null;
|
||||
intakeRemindersEnabled?: boolean; // Medication-level setting (deprecated, use per-intake)
|
||||
isObsolete?: boolean;
|
||||
obsoleteAt?: string | null;
|
||||
dismissedUntil?: string | null; // ISO date string (YYYY-MM-DD) - all past doses until this date are dismissed
|
||||
updatedAt: string | number | null;
|
||||
};
|
||||
@@ -114,6 +117,7 @@ export type FormState = {
|
||||
looseTablets: string; // For blister: extra loose pills; for bottle: current stock
|
||||
pillWeightMg: string;
|
||||
doseUnit: DoseUnit; // Unit for the dose (mg, g, mcg, ml, IU, etc.)
|
||||
medicationStartDate: string;
|
||||
expiryDate: string;
|
||||
notes: string;
|
||||
prescriptionEnabled: boolean;
|
||||
|
||||
@@ -71,11 +71,56 @@ export function getRegionFromTimezone(): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for formatting based on app language and timezone region.
|
||||
* Combines app language (en/de) with region from timezone (DE/US/etc.)
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE (English text, German format)
|
||||
* Map region code to the region's primary language for date/number formatting.
|
||||
* This ensures dates use regional conventions (e.g., dots in Germany)
|
||||
* regardless of the app's UI language.
|
||||
*/
|
||||
const REGION_TO_LANG: Record<string, string> = {
|
||||
DE: "de",
|
||||
AT: "de",
|
||||
CH: "de",
|
||||
GB: "en",
|
||||
IE: "en",
|
||||
FR: "fr",
|
||||
ES: "es",
|
||||
IT: "it",
|
||||
NL: "nl",
|
||||
BE: "nl",
|
||||
PL: "pl",
|
||||
CZ: "cs",
|
||||
SE: "sv",
|
||||
NO: "nb",
|
||||
DK: "da",
|
||||
FI: "fi",
|
||||
GR: "el",
|
||||
PT: "pt",
|
||||
RU: "ru",
|
||||
UA: "uk",
|
||||
HU: "hu",
|
||||
RO: "ro",
|
||||
US: "en",
|
||||
CA: "en",
|
||||
MX: "es",
|
||||
BR: "pt",
|
||||
AR: "es",
|
||||
JP: "ja",
|
||||
CN: "zh",
|
||||
HK: "zh",
|
||||
SG: "en",
|
||||
KR: "ko",
|
||||
AE: "ar",
|
||||
IN: "en",
|
||||
AU: "en",
|
||||
NZ: "en",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get locale for text-based date formatting (weekday names, month names).
|
||||
* Uses the app's UI language + timezone region so text appears in the app language
|
||||
* while regional conventions (day-first order) are respected.
|
||||
*
|
||||
* @param appLanguage - The app's UI language (e.g., 'en', 'de')
|
||||
* Example: app=en + timezone=Europe/Berlin → en-DE
|
||||
* → "Thu, 05. Feb." (English names, German order)
|
||||
*/
|
||||
export function getSystemLocale(appLanguage?: string): string {
|
||||
const region = getRegionFromTimezone();
|
||||
@@ -89,6 +134,25 @@ export function getSystemLocale(appLanguage?: string): string {
|
||||
return navigator.language || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale for purely numeric date/number formatting.
|
||||
* Uses the region's native language so separators match regional conventions
|
||||
* (e.g., dots in Germany: 14.02.2026, slashes in US: 02/14/2026).
|
||||
*
|
||||
* Only use this for numeric-only output (2-digit day/month/year, no text).
|
||||
* For output that includes weekday or month names, use getSystemLocale() instead.
|
||||
*/
|
||||
export function getNumericLocale(): string {
|
||||
const region = getRegionFromTimezone();
|
||||
|
||||
if (region) {
|
||||
const regionLang = REGION_TO_LANG[region] || "en";
|
||||
return `${regionLang}-${region}`;
|
||||
}
|
||||
|
||||
return navigator.language || "en-US";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number using the current locale with optional decimal places
|
||||
*/
|
||||
@@ -114,7 +178,7 @@ export function formatDateTime(iso: string | null | undefined, locale?: string):
|
||||
if (!match) return "-";
|
||||
|
||||
const [, year, month, day, hour, minute] = match;
|
||||
const effectiveLocale = locale ?? getSystemLocale();
|
||||
const effectiveLocale = locale ?? getNumericLocale();
|
||||
|
||||
// Create a date object for formatting, but use local timezone interpretation
|
||||
// by creating the date without the Z suffix
|
||||
@@ -136,6 +200,20 @@ export function formatDateTime(iso: string | null | undefined, locale?: string):
|
||||
return `${dateStr} ${timeStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date-only string (YYYY-MM-DD) or ISO datetime to a localized date (no time).
|
||||
*/
|
||||
export function formatDate(dateStr: string | null | undefined, locale?: string): string {
|
||||
if (!dateStr) return "-";
|
||||
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!match) return "-";
|
||||
const [, year, month, day] = match;
|
||||
const d = new Date(`${year}-${month}-${day}T00:00:00`);
|
||||
if (Number.isNaN(d.getTime())) return "-";
|
||||
const effectiveLocale = locale ?? getNumericLocale();
|
||||
return d.toLocaleDateString(effectiveLocale, { year: "numeric", month: "2-digit", day: "2-digit" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Pad a number to 2 digits with leading zero
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user