mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
Let a medication have several courses
A medication taken now and then (#1024) keeps one row and gains course rows: calendar spans, both ends inclusive, an optional encrypted note, no schedule or dose of their own. Courses never overlap and every course but the latest has ended, so the latest course is always the one the medication's startsOn/endsOn project. src/lib/medications/courses.ts is the only writer of those two columns for a medication with courses; it writes under a per-medication advisory lock and re-projects in the same transaction, so reminders, the next-due line and intakeActionable keep reading the columns and stay right. - Migration 0368 adds medication_courses and backfills one course per medication window (an end-only window starts on the creation day, or on its end day when that lies earlier); a medication without a window stays course-less. Idempotent, data-preserving. - GET/POST /api/medications/{id}/courses and PATCH/DELETE .../courses/{courseId} (owner-only); create seeds the first course from a window; the medication PUT edits the latest course and refuses a window that would break the rules (medications.course.*). - List, detail, create and update carry courses, courseCount, previousCourseEndedOn and canStartCourse, resolved on the server. - Compliance expands over the span of all courses and drops the slots between them; every compliance read selects the courses. A rolling cadence is not anchored by an intake from a previous course. - The card names the last course's end and the running course's number; the detail Lifecycle group lists the courses with start, edit and delete. The Coach compliance block, the MCP resource, the doctor report window and table, the clinician view and FHIR (one MedicationStatement per course with effectivePeriod and status) read the courses. - Backup carries courses per medication and the restore writes them and re-projects; an older file derives the one course its window describes. Wipe plan, encrypted columns, rotation and OpenAPI follow.
This commit is contained in:
1 parent
9b71231010
commit
a14174fe4d
71 files changed
+3635
-90
No files matched your search
@@ -230,7 +230,12 @@ export function ClinicianView({
|
||||
/>
|
||||
|
||||
{/* ── medications ──────────────────────────────────────── */}
|
||||
<MedicationsSection t={t} report={report} scope={scope} />
|
||||
<MedicationsSection
|
||||
t={t}
|
||||
report={report}
|
||||
scope={scope}
|
||||
fmtDate={fmtDate}
|
||||
/>
|
||||
<Glp1Section
|
||||
t={t}
|
||||
report={report}
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
* A pure server component: no client hooks, no session, no markdown — every
|
||||
* value renders as escaped React text.
|
||||
*/
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import {
|
||||
@@ -312,14 +315,22 @@ export function GlucoseSection({
|
||||
}
|
||||
|
||||
/** The medication list with the adherence rate beside each drug. */
|
||||
/** A course day at noon UTC, so no viewer zone shifts it to a neighbour. */
|
||||
function courseNoon(dayKey: string): string {
|
||||
return dateOnlyAtNoonUtc(dayKey).toISOString();
|
||||
}
|
||||
|
||||
export function MedicationsSection({
|
||||
t,
|
||||
report,
|
||||
scope,
|
||||
fmtDate,
|
||||
}: {
|
||||
t: Translate;
|
||||
report: DoctorReportData;
|
||||
scope: LeafScope;
|
||||
/** Formats a calendar date for the viewer. */
|
||||
fmtDate: (iso: string) => string;
|
||||
}) {
|
||||
const medications = scope.admits("MEDICATION_LIST")
|
||||
? (report.medications ?? [])
|
||||
@@ -344,15 +355,33 @@ export function MedicationsSection({
|
||||
? `${Math.round((comp.taken / comp.total) * 100)}%`
|
||||
: null;
|
||||
return (
|
||||
<StatRow
|
||||
key={med.id}
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
<Fragment key={med.id}>
|
||||
<StatRow
|
||||
label={med.dose ? `${med.name} — ${med.dose}` : med.name}
|
||||
value={
|
||||
rate
|
||||
? t("clinicianView.adherence", { rate })
|
||||
: t("clinicianView.noAdherence")
|
||||
}
|
||||
/>
|
||||
{/* v1.40 (#1024) — one row per course inside the window. */}
|
||||
{(med.courses ?? []).map((course, index) => (
|
||||
<StatRow
|
||||
key={`${med.id}-course-${course.startsOn}`}
|
||||
label={t("clinicianView.course", { n: index + 1 })}
|
||||
value={
|
||||
course.endsOn
|
||||
? t("clinicianView.courseRange", {
|
||||
start: fmtDate(courseNoon(course.startsOn)),
|
||||
end: fmtDate(courseNoon(course.endsOn)),
|
||||
})
|
||||
: t("clinicianView.courseSince", {
|
||||
start: fmtDate(courseNoon(course.startsOn)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{complianceEntries
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface MedicationStateBadgesProps {
|
||||
notificationsEnabled: boolean;
|
||||
@@ -10,6 +11,13 @@ interface MedicationStateBadgesProps {
|
||||
recordOnly?: boolean;
|
||||
/** v1.39.4 (#1040) — the course's end date has passed. */
|
||||
courseEnded?: boolean;
|
||||
/**
|
||||
* v1.40 (#1024) — the server's `previousCourseEndedOn`: an ended course
|
||||
* names its last day ("Last course ended 16 Jun").
|
||||
*/
|
||||
lastCourseEndedOn?: string | null;
|
||||
/** v1.40 (#1024) — the running course's number, when there are several. */
|
||||
courseNumber?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,8 +32,11 @@ export function MedicationStateBadges({
|
||||
pausedAt,
|
||||
recordOnly = false,
|
||||
courseEnded = false,
|
||||
lastCourseEndedOn = null,
|
||||
courseNumber = null,
|
||||
}: MedicationStateBadgesProps) {
|
||||
const { t } = useTranslations();
|
||||
const { t, locale } = useTranslations();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -44,7 +55,24 @@ export function MedicationStateBadges({
|
||||
className="text-xs"
|
||||
data-slot="medication-course-ended-badge"
|
||||
>
|
||||
{t("medications.courseEndedBadge")}
|
||||
{lastCourseEndedOn
|
||||
? t("medications.course.endedOn", {
|
||||
date: formatDate(
|
||||
new Date(lastCourseEndedOn),
|
||||
dateFormatPref,
|
||||
locale,
|
||||
),
|
||||
})
|
||||
: t("medications.courseEndedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{!courseEnded && courseNumber !== null && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
data-slot="medication-course-number-badge"
|
||||
>
|
||||
{t("medications.course.nth", { n: courseNumber })}
|
||||
</Badge>
|
||||
)}
|
||||
{!notificationsEnabled && !recordOnly && !courseEnded && (
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* v1.40 (#1024) — the course fields every medication read carries, as the
|
||||
* client consumes them. Server-resolved: render, never recompute.
|
||||
*/
|
||||
export interface MedicationCourseWire {
|
||||
id: string;
|
||||
/** `YYYY-MM-DD`, inclusive. */
|
||||
startsOn: string;
|
||||
/** `YYYY-MM-DD`, inclusive; null = open. */
|
||||
endsOn: string | null;
|
||||
status: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
takenDoses: number;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
export interface MedicationCourseFields {
|
||||
courses?: MedicationCourseWire[];
|
||||
courseCount?: number;
|
||||
previousCourseEndedOn?: string | null;
|
||||
canStartCourse?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The 1-based number of the course running or about to run, when the
|
||||
* medication has more than one; null otherwise (nothing to number).
|
||||
*/
|
||||
export function currentCourseNumber(
|
||||
fields: MedicationCourseFields,
|
||||
): number | null {
|
||||
const courses = fields.courses ?? [];
|
||||
if (courses.length < 2) return null;
|
||||
const index = courses.findIndex((c) => c.status !== "ENDED");
|
||||
return index === -1 ? null : index + 1;
|
||||
}
|
||||
@@ -31,6 +31,8 @@
|
||||
* administration.
|
||||
*/
|
||||
|
||||
import type { MedicationCourseWire } from "@/components/medications/course-fields";
|
||||
import { CoursesRow } from "@/components/medications/sections/courses-section";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import {
|
||||
useCallback,
|
||||
@@ -174,6 +176,9 @@ export interface MedicationDetailSnapshot {
|
||||
trackIntake?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course (server-resolved). */
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
/** v1.40 (#1024) — the server-resolved courses. */
|
||||
courses?: MedicationCourseWire[];
|
||||
canStartCourse?: boolean;
|
||||
recordedSchedules?: ScheduleSnapshot[];
|
||||
schedules: ScheduleSnapshot[];
|
||||
}
|
||||
@@ -907,6 +912,16 @@ export function MedicationDetailTabs({
|
||||
active={medication.active}
|
||||
/>
|
||||
</div>
|
||||
{/* v1.40 (#1024) — the courses: when it was taken, and a new
|
||||
course once the last one has ended. */}
|
||||
<div className="py-3">
|
||||
<CoursesRow
|
||||
medicationId={id}
|
||||
courses={medication.courses ?? []}
|
||||
canStartCourse={medication.canStartCourse === true}
|
||||
canEdit={!inSharedRecord}
|
||||
/>
|
||||
</div>
|
||||
{/* The structural editor (the create/edit wizard) — name,
|
||||
class, cadence kind, schedules. Replaces the former hero
|
||||
"Vollständig bearbeiten" button, which read as "edit this
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
currentCourseNumber,
|
||||
type MedicationCourseFields,
|
||||
} from "@/components/medications/course-fields";
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -144,6 +148,11 @@ export interface Glp1Medication {
|
||||
intakeActionable?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course. */
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
/** v1.40 (#1024) — the server-resolved course fields. */
|
||||
courses?: MedicationCourseFields["courses"];
|
||||
courseCount?: number;
|
||||
previousCourseEndedOn?: string | null;
|
||||
canStartCourse?: boolean;
|
||||
schedules: ScheduleLite[];
|
||||
}
|
||||
|
||||
@@ -456,6 +465,8 @@ export function Glp1MedicationCard({
|
||||
pausedAt={medication.pausedAt}
|
||||
recordOnly={medication.trackIntake === false}
|
||||
courseEnded={medication.courseStatus === "ENDED"}
|
||||
lastCourseEndedOn={medication.previousCourseEndedOn ?? null}
|
||||
courseNumber={currentCourseNumber(medication)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
currentCourseNumber,
|
||||
type MedicationCourseFields,
|
||||
} from "@/components/medications/course-fields";
|
||||
import { useState, useEffect, useReducer } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -129,6 +133,11 @@ interface Medication {
|
||||
intakeActionable?: boolean;
|
||||
/** v1.39.4 (#1040) — where today sits in the course. */
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
/** v1.40 (#1024) — the server-resolved course fields. */
|
||||
courses?: MedicationCourseFields["courses"];
|
||||
courseCount?: number;
|
||||
previousCourseEndedOn?: string | null;
|
||||
canStartCourse?: boolean;
|
||||
schedules: Schedule[];
|
||||
}
|
||||
|
||||
@@ -399,6 +408,8 @@ export function MedicationCard({
|
||||
pausedAt={medication.pausedAt}
|
||||
recordOnly={medication.trackIntake === false}
|
||||
courseEnded={medication.courseStatus === "ENDED"}
|
||||
lastCourseEndedOn={medication.previousCourseEndedOn ?? null}
|
||||
courseNumber={currentCourseNumber(medication)}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { MedicationCourseFields } from "@/components/medications/course-fields";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useEffect, useReducer, useState } from "react";
|
||||
import Link from "next/link";
|
||||
@@ -33,7 +34,12 @@ import { reduceCurrentWindowStatus } from "@/lib/medications/window-status";
|
||||
import { resolveNextDueDayLabel } from "@/lib/medications/next-due-day-label";
|
||||
import { formatTime } from "@/lib/format";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import { useTranslations, useFormatters } from "@/lib/i18n/context";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import {
|
||||
useDateFormatPreference,
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
} from "@/lib/i18n/context";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
@@ -104,6 +110,11 @@ export interface TableMedication {
|
||||
*/
|
||||
intakeActionable?: boolean;
|
||||
courseStatus?: "UPCOMING" | "CURRENT" | "ENDED";
|
||||
/** v1.40 (#1024) — the server-resolved course fields. */
|
||||
courses?: MedicationCourseFields["courses"];
|
||||
courseCount?: number;
|
||||
previousCourseEndedOn?: string | null;
|
||||
canStartCourse?: boolean;
|
||||
/** v1.16.10 — dose-derived stock from the list payload; null = inventory tracking off. */
|
||||
stockDosesRemaining?: number | null;
|
||||
/** v1.37.19 — server-resolved slot-aware runway (days); null = off/no cadence. */
|
||||
@@ -412,6 +423,7 @@ function MedicationTableRowItem({
|
||||
highlighted = false,
|
||||
}: MedicationTableRowItemProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const dateFormatPref = useDateFormatPreference();
|
||||
const fmt = useFormatters();
|
||||
const weekdayLabel = useWeekdayLabel();
|
||||
|
||||
@@ -542,7 +554,15 @@ function MedicationTableRowItem({
|
||||
className="text-muted-foreground"
|
||||
data-slot="medication-table-course-ended-marker"
|
||||
>
|
||||
{t("medications.courseEndedBadge")}
|
||||
{medication.previousCourseEndedOn
|
||||
? t("medications.course.endedOn", {
|
||||
date: formatDate(
|
||||
new Date(medication.previousCourseEndedOn),
|
||||
dateFormatPref,
|
||||
locale,
|
||||
),
|
||||
})
|
||||
: t("medications.courseEndedBadge")}
|
||||
</span>
|
||||
);
|
||||
} else if (medication.asNeeded) {
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.40 (#1024) — the courses of a medication, in the detail page's
|
||||
* Lifecycle group: one row per course (dates, where today sits, doses
|
||||
* taken), "Start a new course" once every course has ended, and edit /
|
||||
* delete per course. The rows follow the Lifecycle rows beside them (title +
|
||||
* muted helper, actions at the trailing edge); the editor is a
|
||||
* ResponsiveSheet with DateFields and inline refusals.
|
||||
*
|
||||
* Every figure is the server's (`courses`, `canStartCourse`); this file
|
||||
* renders them and computes nothing.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarPlus, Loader2, Pencil, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DateField } from "@/components/ui/date-field";
|
||||
import { FieldError } from "@/components/forms/field-error";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import type { MedicationCourseWire } from "@/components/medications/course-fields";
|
||||
import { ApiError, apiDelete, apiPatch, apiPost } from "@/lib/api/api-fetch";
|
||||
import { formatDate } from "@/lib/date-format";
|
||||
import { useDateFormatPreference, useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
|
||||
/** The refusal codes the course routes answer, mapped to their copy. */
|
||||
const REFUSAL_KEYS: Record<string, string> = {
|
||||
"medications.course.overlap": "medications.course.error.overlap",
|
||||
"medications.course.currentOpen": "medications.course.error.currentOpen",
|
||||
"medications.course.oneShot": "medications.course.error.oneShot",
|
||||
"medications.course.invalidRange": "medications.course.error.invalidRange",
|
||||
};
|
||||
|
||||
function useCourseDate() {
|
||||
const { locale } = useTranslations();
|
||||
const pref = useDateFormatPreference();
|
||||
return (key: string) => formatDate(new Date(key), pref, locale);
|
||||
}
|
||||
|
||||
function CourseEditor({
|
||||
medicationId,
|
||||
course,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
medicationId: string;
|
||||
/** Absent → start a new course. */
|
||||
course?: MedicationCourseWire;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [startsOn, setStartsOn] = useState(course?.startsOn ?? "");
|
||||
const [endsOn, setEndsOn] = useState(course?.endsOn ?? "");
|
||||
const [note, setNote] = useState(course?.note ?? "");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const rangeInvalid = startsOn !== "" && endsOn !== "" && endsOn < startsOn;
|
||||
|
||||
async function save() {
|
||||
if (!startsOn || rangeInvalid || saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const body = {
|
||||
startsOn,
|
||||
endsOn: endsOn === "" ? null : endsOn,
|
||||
note: note.trim() === "" ? null : note.trim(),
|
||||
};
|
||||
try {
|
||||
if (course) {
|
||||
await apiPatch(
|
||||
`/api/medications/${medicationId}/courses/${course.id}`,
|
||||
body,
|
||||
);
|
||||
} else {
|
||||
await apiPost(`/api/medications/${medicationId}/courses`, body);
|
||||
}
|
||||
await invalidateMedicationReads(queryClient);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const code =
|
||||
err instanceof ApiError
|
||||
? (err.meta?.errorCode as string | undefined)
|
||||
: undefined;
|
||||
setError(
|
||||
t(
|
||||
code && REFUSAL_KEYS[code]
|
||||
? REFUSAL_KEYS[code]
|
||||
: "medications.course.error.generic",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={
|
||||
course
|
||||
? t("medications.course.editTitle")
|
||||
: t("medications.course.startNew")
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
disabled={!startsOn || rangeInvalid || saving}
|
||||
data-slot="medication-course-save"
|
||||
>
|
||||
{saving && (
|
||||
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4" data-slot="medication-course-editor">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="medication-course-start">
|
||||
{t("medications.course.startsOn")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="medication-course-start"
|
||||
value={startsOn}
|
||||
onChange={setStartsOn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="medication-course-end">
|
||||
{t("medications.course.endsOn")}
|
||||
</Label>
|
||||
<DateField
|
||||
id="medication-course-end"
|
||||
value={endsOn}
|
||||
min={startsOn || undefined}
|
||||
onChange={setEndsOn}
|
||||
aria-invalid={rangeInvalid || undefined}
|
||||
aria-describedby="medication-course-end-error"
|
||||
/>
|
||||
<FieldError
|
||||
id="medication-course-end-error"
|
||||
message={
|
||||
rangeInvalid
|
||||
? t("medications.course.error.invalidRange")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="medication-course-note">
|
||||
{t("medications.course.note")}
|
||||
</Label>
|
||||
<Input
|
||||
id="medication-course-note"
|
||||
value={note}
|
||||
maxLength={280}
|
||||
placeholder={t("medications.course.notePlaceholder")}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<FieldError id="medication-course-error" message={error ?? undefined} />
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoursesRow({
|
||||
medicationId,
|
||||
courses,
|
||||
canStartCourse,
|
||||
canEdit,
|
||||
}: {
|
||||
medicationId: string;
|
||||
courses: MedicationCourseWire[];
|
||||
canStartCourse: boolean;
|
||||
/** Course writes are the owner's; a shared record shows the list only. */
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const date = useCourseDate();
|
||||
const [editing, setEditing] = useState<MedicationCourseWire | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [deleting, setDeleting] = useState<MedicationCourseWire | null>(null);
|
||||
const [deleteBusy, setDeleteBusy] = useState(false);
|
||||
|
||||
async function confirmDelete(course: MedicationCourseWire) {
|
||||
setDeleteBusy(true);
|
||||
try {
|
||||
await apiDelete(`/api/medications/${medicationId}/courses/${course.id}`);
|
||||
await invalidateMedicationReads(queryClient);
|
||||
setDeleting(null);
|
||||
} catch {
|
||||
toast.error(t("medications.course.error.generic"));
|
||||
} finally {
|
||||
setDeleteBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: MedicationCourseWire["status"]) =>
|
||||
status === "CURRENT"
|
||||
? t("medications.course.status.current")
|
||||
: status === "UPCOMING"
|
||||
? t("medications.course.status.upcoming")
|
||||
: t("medications.course.status.ended");
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-slot="medication-courses-row">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="text-foreground text-sm font-medium">
|
||||
{t("medications.course.title")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.course.helper")}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && canStartCourse && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStarting(true)}
|
||||
className="min-h-11 shrink-0 sm:min-h-9"
|
||||
data-slot="medication-course-start-new"
|
||||
>
|
||||
<CalendarPlus aria-hidden="true" className="size-4" />
|
||||
{t("medications.course.startNew")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{courses.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.course.none")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{courses.map((course, index) => (
|
||||
<li
|
||||
key={course.id}
|
||||
className="border-border flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
data-slot="medication-course-item"
|
||||
data-status={course.status}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm">
|
||||
{course.endsOn
|
||||
? t("medications.course.range", {
|
||||
start: date(course.startsOn),
|
||||
end: date(course.endsOn),
|
||||
})
|
||||
: t("medications.course.since", {
|
||||
start: date(course.startsOn),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.course.meta", {
|
||||
n: index + 1,
|
||||
status: statusLabel(course.status),
|
||||
taken: course.takenDoses,
|
||||
})}
|
||||
</p>
|
||||
{course.note && (
|
||||
<p className="text-sm break-words">{course.note}</p>
|
||||
)}
|
||||
</div>
|
||||
{canEdit && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => setEditing(course)}
|
||||
aria-label={t("medications.course.editTitle")}
|
||||
title={t("medications.course.editTitle")}
|
||||
>
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive size-11 sm:size-9"
|
||||
onClick={() => setDeleting(course)}
|
||||
aria-label={t("medications.course.delete")}
|
||||
title={t("medications.course.delete")}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{starting && (
|
||||
<CourseEditor
|
||||
medicationId={medicationId}
|
||||
open={starting}
|
||||
onOpenChange={setStarting}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<CourseEditor
|
||||
medicationId={medicationId}
|
||||
course={editing}
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleting(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.course.deleteTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{courses.length === 1
|
||||
? t("medications.course.deleteLastConfirm")
|
||||
: t("medications.course.deleteBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteBusy}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (deleting) void confirmDelete(deleting);
|
||||
}}
|
||||
>
|
||||
{t("medications.course.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user