mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
chore(medications): drop the Dosis-Historie disclosure from GLP-1 detail
Retire the inline dose-history `<details>` block on the GLP-1 medication card and the IntakeHistoryList mount on the standalone medication detail page. The IntakeHistoryList component had no other consumer, so the file retires together with its orphan i18n keys. The iOS-consumed GET /api/medications/[id]/intake endpoint, its Zod schema and the doseChanges wire field on /api/medications/[id]/glp1 all stay byte-stable.
This commit is contained in:
1 parent
52edf85f02
commit
8c81af10d6
12 files changed
+42
-1097
No files matched your search
+1
-17
@@ -443,20 +443,7 @@
|
||||
"daysThu": "Do",
|
||||
"daysFri": "Fr",
|
||||
"daysSat": "Sa",
|
||||
"intakeHistoryTitle": "Einnahme-Verlauf",
|
||||
"back": "Zurück",
|
||||
"newIntake": "Hinzufügen",
|
||||
"editIntake": "Einnahme bearbeiten",
|
||||
"intakeScheduledFor": "Geplant",
|
||||
"intakeTakenAt": "Eingenommen um",
|
||||
"intakeStatus": "Status",
|
||||
"intakeSource": "Quelle",
|
||||
"intakeStatusTaken": "Eingenommen",
|
||||
"intakeStatusSkipped": "Übersprungen",
|
||||
"intakeStatusMissed": "Verpasst",
|
||||
"intakeDeleteConfirm": "Einnahme-Eintrag löschen?",
|
||||
"intakeDeleteDescription": "Dieser Einnahme-Eintrag wird unwiderruflich gelöscht.",
|
||||
"intakeCount": "{count} Einnahmen",
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern",
|
||||
"tomorrow": "Morgen",
|
||||
@@ -491,7 +478,7 @@
|
||||
"formUnit": "Einheit",
|
||||
"formSchedule": "Zeitfenster",
|
||||
"formTreatmentClass": "Behandlungsklasse",
|
||||
"formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte, die Dosis-Historie und den Pen-Bestand unten frei.",
|
||||
"formTreatmentClassHelp": "Wähle „GLP-1-Injektion“ für wöchentliche Spritzen wie Mounjaro, Ozempic, Wegovy, Zepbound. Schaltet die Körperkarte und das GLP-1-Logbuch frei.",
|
||||
"treatmentClassGeneric": "Standard-Medikament",
|
||||
"treatmentClassGlp1": "GLP-1-Injektion",
|
||||
"glp1WeeklyPresetTitle": "Einmal wöchentlich am …",
|
||||
@@ -505,9 +492,6 @@
|
||||
"glp1NextInjectionDays": "Nächster Termin: {label} (in {days} Tagen)",
|
||||
"glp1NextInjectionToday": "Nächster Termin: heute",
|
||||
"glp1NextInjectionTomorrow": "Nächster Termin: morgen",
|
||||
"glp1DoseHistory": "Dosis-Historie",
|
||||
"glp1DoseHistoryEmpty": "Noch keine Dosis-Änderung erfasst.",
|
||||
"glp1DoseSince": "seit {date}",
|
||||
"glp1RotationLast": "Letzte Stelle: {site}",
|
||||
"glp1RotationSuggested": "Empfohlen: {site}",
|
||||
"glp1Inventory": "{pens} Pens übrig · ~{weeks} Wochen Reichweite",
|
||||
|
||||
+1
-17
@@ -443,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Back",
|
||||
"newIntake": "Add",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Status",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Taken",
|
||||
"intakeStatusSkipped": "Skipped",
|
||||
"intakeStatusMissed": "Missed",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"tomorrow": "Tomorrow",
|
||||
@@ -491,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -505,9 +492,6 @@
|
||||
"glp1NextInjectionDays": "Next: {label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
|
||||
+1
-17
@@ -443,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Atrás",
|
||||
"newIntake": "Añadir",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Estado",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Tomada",
|
||||
"intakeStatusSkipped": "Omitida",
|
||||
"intakeStatusMissed": "Perdida",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Hoy",
|
||||
"yesterday": "Ayer",
|
||||
"tomorrow": "Mañana",
|
||||
@@ -491,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -505,9 +492,6 @@
|
||||
"glp1NextInjectionDays": "Siguiente:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
|
||||
+1
-17
@@ -443,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Retour",
|
||||
"newIntake": "Ajouter",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Statut",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Pris",
|
||||
"intakeStatusSkipped": "Sauté",
|
||||
"intakeStatusMissed": "Manqué",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Aujourd'hui",
|
||||
"yesterday": "Hier",
|
||||
"tomorrow": "Demain",
|
||||
@@ -491,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -505,9 +492,6 @@
|
||||
"glp1NextInjectionDays": "Suivant:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
|
||||
+1
-17
@@ -443,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Indietro",
|
||||
"newIntake": "Aggiungi",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Stato",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Assunta",
|
||||
"intakeStatusSkipped": "Saltata",
|
||||
"intakeStatusMissed": "Persa",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Oggi",
|
||||
"yesterday": "Ieri",
|
||||
"tomorrow": "Domani",
|
||||
@@ -491,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -505,9 +492,6 @@
|
||||
"glp1NextInjectionDays": "Avanti:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
|
||||
+1
-17
@@ -443,20 +443,7 @@
|
||||
"daysThu": "Thu",
|
||||
"daysFri": "Fri",
|
||||
"daysSat": "Sat",
|
||||
"intakeHistoryTitle": "Intake History",
|
||||
"back": "Wstecz",
|
||||
"newIntake": "Dodaj",
|
||||
"editIntake": "Edit Intake",
|
||||
"intakeScheduledFor": "Scheduled",
|
||||
"intakeTakenAt": "Taken at",
|
||||
"intakeStatus": "Status",
|
||||
"intakeSource": "Source",
|
||||
"intakeStatusTaken": "Przyjęte",
|
||||
"intakeStatusSkipped": "Pominięte",
|
||||
"intakeStatusMissed": "Pominięte",
|
||||
"intakeDeleteConfirm": "Delete intake record?",
|
||||
"intakeDeleteDescription": "This intake record will be permanently deleted.",
|
||||
"intakeCount": "{count} intakes",
|
||||
"today": "Dzisiaj",
|
||||
"yesterday": "Wczoraj",
|
||||
"tomorrow": "Jutro",
|
||||
@@ -491,7 +478,7 @@
|
||||
"formUnit": "Unit",
|
||||
"formSchedule": "Schedule",
|
||||
"formTreatmentClass": "Treatment class",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker, dose-history view, and pen inventory below.",
|
||||
"formTreatmentClassHelp": "Pick \"GLP-1 injection\" for weekly injectables like Mounjaro, Ozempic, Wegovy, Zepbound. Unlocks the body-map picker and the GLP-1 logbook below.",
|
||||
"treatmentClassGeneric": "Standard medication",
|
||||
"treatmentClassGlp1": "GLP-1 injection",
|
||||
"glp1WeeklyPresetTitle": "Once weekly on …",
|
||||
@@ -505,9 +492,6 @@
|
||||
"glp1NextInjectionDays": "Dalej:{label} (in {days} days)",
|
||||
"glp1NextInjectionToday": "Next: today",
|
||||
"glp1NextInjectionTomorrow": "Next: tomorrow",
|
||||
"glp1DoseHistory": "Dose history",
|
||||
"glp1DoseHistoryEmpty": "No dose changes logged yet.",
|
||||
"glp1DoseSince": "since {date}",
|
||||
"glp1RotationLast": "Last site: {site}",
|
||||
"glp1RotationSuggested": "Recommended next: {site}",
|
||||
"glp1Inventory": "{pens} pens left · ~{weeks} weeks of supply",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, use, useState } from "react";
|
||||
import { useEffect, use } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IntakeHistoryList } from "@/components/medications/intake-history-list";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import { SchedulingSection } from "@/components/medications/SchedulingSection";
|
||||
import { TitrationSection } from "@/components/medications/TitrationSection";
|
||||
import { ArrowLeft, Loader2, Plus } from "lucide-react";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
@@ -23,7 +22,6 @@ export default function IntakeHistoryPage({
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
@@ -70,22 +68,18 @@ export default function IntakeHistoryPage({
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.intakeHistoryTitle")}
|
||||
</h1>
|
||||
{medication && (
|
||||
{medication && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{medication.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{medication.name} — {medication.dose}
|
||||
{medication.dose}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("medications.newIntake")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.25 W19c-Frontend — Research-mode-gated drug-level chart.
|
||||
Renders only for GLP-1 medications; the component itself
|
||||
@@ -127,18 +121,12 @@ export default function IntakeHistoryPage({
|
||||
{/* v1.4.25 W19f — GLP-1 titration-ladder display. Read-only EMA
|
||||
reference visual showing the standard dose-escalation schedule
|
||||
with the user's current step highlighted. Sits between
|
||||
SchedulingSection and IntakeHistoryList so the user lands on
|
||||
cycle context (chart) -> side effects -> cadence/adherence ->
|
||||
titration position -> per-dose timeline. */}
|
||||
SchedulingSection and the bottom of the page; v1.4.28 retired
|
||||
the IntakeHistoryList block that used to anchor the per-dose
|
||||
timeline below this section. */}
|
||||
{medication?.treatmentClass === "GLP1" && (
|
||||
<TitrationSection medicationId={id} />
|
||||
)}
|
||||
|
||||
<IntakeHistoryList
|
||||
medicationId={id}
|
||||
createOpen={createOpen}
|
||||
onCreateOpenChange={setCreateOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,9 +13,10 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
|
||||
/**
|
||||
* v1.4.25 W19e — GLP-1 cadence + compliance section.
|
||||
*
|
||||
* Sits between `<SideEffectsSection>` (W19d) and `<IntakeHistoryList>`
|
||||
* on the medication detail page. Same chrome as the W19d / W19f
|
||||
* sections, composed via the shared `<MedicationDetailSection>` wrapper.
|
||||
* Sits between `<SideEffectsSection>` (W19d) and `<TitrationSection>`
|
||||
* on the medication detail page (v1.4.28 retired the IntakeHistoryList
|
||||
* block below). Same chrome as the W19d / W19f sections, composed via
|
||||
* the shared `<MedicationDetailSection>` wrapper.
|
||||
*
|
||||
* Three sub-sections, top-to-bottom:
|
||||
*
|
||||
|
||||
@@ -10,10 +10,11 @@ import { MedicationDetailSection } from "@/components/medications/medication-det
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder section.
|
||||
*
|
||||
* Sits between `<SchedulingSection>` (W19e) and `<IntakeHistoryList>`
|
||||
* on the medication detail page. Same chrome as the W19d / W19e
|
||||
* sections so the Wave-4b panels read as one visual group — chrome is
|
||||
* now composed via the shared `<MedicationDetailSection>` wrapper.
|
||||
* Sits at the bottom of the medication detail page below the W19e
|
||||
* SchedulingSection (v1.4.28 retired the IntakeHistoryList block).
|
||||
* Same chrome as the W19d / W19e sections so the panels read as one
|
||||
* visual group — chrome is now composed via the shared
|
||||
* `<MedicationDetailSection>` wrapper.
|
||||
*
|
||||
* Layout:
|
||||
* - Header: drug INN + "Standard ladder (EMA reference)"
|
||||
|
||||
@@ -245,11 +245,11 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).toMatch(/Next:/);
|
||||
});
|
||||
|
||||
it("renders the inline dose-history disclosure (closed by default)", () => {
|
||||
// The collapsible <details> element keeps the card height matched
|
||||
// to a generic card on first paint. SSR includes the element with
|
||||
// no `open=""` attribute; clicking the summary toggles state via
|
||||
// React's onToggle handler (not exercisable from SSR).
|
||||
it("no longer renders the inline dose-history disclosure (retired v1.4.28)", () => {
|
||||
// The dose-history `<details>` block was retired in v1.4.28 per
|
||||
// maintainer feedback. The doseChanges payload still arrives from
|
||||
// the API (iOS contract preserved), but the GLP-1 card no longer
|
||||
// paints the disclosure or the summary label.
|
||||
const client = makeClient();
|
||||
seedCompliance(client, med7p5.id);
|
||||
seedGlp1Details(client, med7p5.id, {
|
||||
@@ -276,16 +276,8 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
client,
|
||||
);
|
||||
|
||||
// The <details> wrapper renders with the localised summary.
|
||||
expect(html).toMatch(/<details[^>]*>/);
|
||||
expect(html).toContain("Dose history");
|
||||
// Closed by default — no `open=""` attribute on the <details>.
|
||||
const detailsTag = html.match(/<details[^>]*>/)?.[0] ?? "";
|
||||
expect(detailsTag).not.toMatch(/\sopen(=""|\s|>)/);
|
||||
// Dose-history rows are present in the markup even though they're
|
||||
// visually hidden until expanded.
|
||||
expect(html).toContain("5 mg");
|
||||
expect(html).toContain("7.5 mg");
|
||||
expect(html).not.toContain("Dose history");
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
|
||||
it("renders the injection-site rotation marker (last + recommended)", () => {
|
||||
@@ -475,6 +467,7 @@ describe("<Glp1MedicationCard> — GLP-1 variant rendering", () => {
|
||||
expect(html).toContain("GLP-1-Injektion");
|
||||
expect(html).toContain("Letzter Termin:");
|
||||
expect(html).toContain("Bauch, unten links");
|
||||
expect(html).toContain("Dosis-Historie");
|
||||
// v1.4.28 retired the "Dosis-Historie" disclosure on the GLP-1 card.
|
||||
expect(html).not.toContain("Dosis-Historie");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Flame,
|
||||
History,
|
||||
Loader2,
|
||||
@@ -34,9 +33,9 @@ import { InventorySection } from "@/components/medications/inventory-section";
|
||||
*
|
||||
* Marc directive 2026-05-14: NO chart on the medication card. The card
|
||||
* stays text-rich (drug name + current dose, last/next injection,
|
||||
* inline dose-history disclosure, injection-site rotation hint, pen
|
||||
* inventory, side-effect quick-log). Chart-only surfaces are the
|
||||
* Dashboard tile + Insights /medikamente sub-page.
|
||||
* injection-site rotation hint, pen inventory, side-effect quick-log).
|
||||
* v1.4.28 retired the inline dose-history disclosure. Chart-only
|
||||
* surfaces are the Dashboard tile + Insights /medikamente sub-page.
|
||||
*
|
||||
* Renders inside the standard medications grid alongside generic
|
||||
* medication cards; visually mirrors `medication-card.tsx` so the page
|
||||
@@ -160,8 +159,6 @@ export function Glp1MedicationCard({
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const [intakeLoading, setIntakeLoading] = useState<string | null>(null);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const doseHistoryId = useId();
|
||||
|
||||
const { data: compliance } = useQuery({
|
||||
queryKey: ["medications", medication.id, "compliance"],
|
||||
@@ -213,7 +210,6 @@ export function Glp1MedicationCard({
|
||||
const rate30 = compliance?.compliance30?.rate ?? 0;
|
||||
const streak = compliance?.compliance7?.streak ?? 0;
|
||||
|
||||
const doseChanges = details?.doseChanges ?? [];
|
||||
const recentInjections = details?.recentIntakes ?? [];
|
||||
const lastSite =
|
||||
recentInjections.find((i) => i.injectionSite)?.injectionSite ?? null;
|
||||
@@ -359,58 +355,6 @@ export function Glp1MedicationCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dose-history disclosure — collapsible row. Stays closed by
|
||||
default so the card height matches a generic card on first
|
||||
paint. */}
|
||||
<details
|
||||
className="border-border/60 rounded-md border text-xs"
|
||||
open={showHistory}
|
||||
onToggle={(e) =>
|
||||
setShowHistory((e.target as HTMLDetailsElement).open)
|
||||
}
|
||||
>
|
||||
<summary
|
||||
className="text-foreground/85 flex cursor-pointer list-none items-center justify-between px-3 py-2 font-medium"
|
||||
aria-controls={doseHistoryId}
|
||||
aria-expanded={showHistory}
|
||||
>
|
||||
<span>{t("medications.glp1DoseHistory")}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${
|
||||
showHistory ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</summary>
|
||||
<div
|
||||
id={doseHistoryId}
|
||||
className="border-border/60 space-y-1.5 border-t px-3 py-2"
|
||||
>
|
||||
{doseChanges.length === 0 && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("medications.glp1DoseHistoryEmpty")}
|
||||
</p>
|
||||
)}
|
||||
{doseChanges
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((change) => (
|
||||
<div
|
||||
key={change.id}
|
||||
className="flex items-baseline justify-between gap-2"
|
||||
>
|
||||
<span className="font-medium tabular-nums">
|
||||
{change.doseValue} {change.doseUnit}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("medications.glp1DoseSince", {
|
||||
date: fmt.dateShort(new Date(change.effectiveFrom)),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* v1.4.25 W19b — per-pen / per-vial inventory disclosure.
|
||||
Lazy-loads the inventory list on open; otherwise stays
|
||||
collapsed so the card height matches generic cards. */}
|
||||
|
||||
@@ -1,886 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { DateTimeInput } from "@/components/ui/date-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Check,
|
||||
SkipForward,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
import { useId, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
interface IntakeEvent {
|
||||
id: string;
|
||||
medicationId: string;
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface IntakeHistoryListProps {
|
||||
medicationId: string;
|
||||
createOpen?: boolean;
|
||||
onCreateOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// SOURCE_LABELS built dynamically via t() in the component
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function toDateTimeLocalValue(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60 * 1000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function dateTimeLocalToISO(value: string): string {
|
||||
return new Date(value).toISOString();
|
||||
}
|
||||
|
||||
export function IntakeHistoryList({
|
||||
medicationId,
|
||||
createOpen,
|
||||
onCreateOpenChange,
|
||||
}: IntakeHistoryListProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
WEB: t("medications.sourceWeb"),
|
||||
API: t("medications.sourceApi"),
|
||||
REMINDER: t("medications.sourceReminder"),
|
||||
IMPORT: t("medications.sourceImport"),
|
||||
};
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState<string>("scheduledFor");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// Edit state
|
||||
const [editing, setEditing] = useState<IntakeEvent | null>(null);
|
||||
const [editScheduledFor, setEditScheduledFor] = useState("");
|
||||
const [editTakenAt, setEditTakenAt] = useState("");
|
||||
const [editSkipped, setEditSkipped] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [editDeleteDialogOpen, setEditDeleteDialogOpen] = useState(false);
|
||||
// v1.4.27 MB3 — error banners reference the form inputs via
|
||||
// aria-describedby so screen readers announce validation failures.
|
||||
const editErrorId = useId();
|
||||
const editErrorDescriptor = editError ? editErrorId : undefined;
|
||||
|
||||
// v1.4.27 R4 RC2 — portal targets + stable form ids so the edit and
|
||||
// create sheets sticky-pin Save / Cancel via the
|
||||
// `<ResponsiveSheet>` footer slot. Submit buttons keep their `<form>`
|
||||
// association via the HTML `form` attribute.
|
||||
const editFormId = useId();
|
||||
const createFormId = useId();
|
||||
const [editFooterEl, setEditFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const [createFooterEl, setCreateFooterEl] = useState<HTMLDivElement | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Create state — controlled via props or internal
|
||||
const [internalCreating, setInternalCreating] = useState(false);
|
||||
const creating = createOpen ?? internalCreating;
|
||||
const [createScheduledFor, setCreateScheduledFor] = useState("");
|
||||
const [createTakenAt, setCreateTakenAt] = useState("");
|
||||
const [createSkipped, setCreateSkipped] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const createErrorId = useId();
|
||||
const createErrorDescriptor = createError ? createErrorId : undefined;
|
||||
const setCreatingRaw = onCreateOpenChange ?? setInternalCreating;
|
||||
const setCreating = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
const now = toDateTimeLocalValue(new Date().toISOString());
|
||||
setCreateScheduledFor(now);
|
||||
setCreateTakenAt(now);
|
||||
setCreateSkipped(false);
|
||||
setCreateError(null);
|
||||
}
|
||||
setCreatingRaw(open);
|
||||
},
|
||||
[setCreatingRaw],
|
||||
);
|
||||
|
||||
function toggleSort(column: string) {
|
||||
if (sortBy === column) {
|
||||
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortDir(column === "scheduledFor" ? "desc" : "asc");
|
||||
}
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["medications", medicationId, "intake", page, sortBy, sortDir],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(PAGE_SIZE));
|
||||
params.set("offset", String((page - 1) * PAGE_SIZE));
|
||||
params.set("sortBy", sortBy);
|
||||
params.set("sortDir", sortDir);
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake?${params}`,
|
||||
);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
events: IntakeEvent[];
|
||||
meta: { total: number; limit: number; offset: number };
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (eventId: string) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${eventId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
},
|
||||
onSuccess: () => {
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
eventId,
|
||||
takenAt,
|
||||
skipped,
|
||||
scheduledFor,
|
||||
}: {
|
||||
eventId: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
scheduledFor: string;
|
||||
}) => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/${eventId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ takenAt, skipped, scheduledFor }),
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? "Update failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setEditError(
|
||||
err instanceof Error ? err.message : t("medications.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
scheduledFor,
|
||||
takenAt,
|
||||
skipped,
|
||||
}: {
|
||||
scheduledFor: string;
|
||||
takenAt: string | null;
|
||||
skipped: boolean;
|
||||
}) => {
|
||||
const res = await fetch(`/api/medications/${medicationId}/intake`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scheduledFor, takenAt, skipped }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!res.ok) throw new Error(json.error ?? "Create failed");
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await invalidateKeys(queryClient, medicationDependentKeys);
|
||||
setCreating(false);
|
||||
setCreateError(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setCreateError(
|
||||
err instanceof Error ? err.message : t("medications.saveError"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const totalPages = data ? Math.ceil(data.meta.total / PAGE_SIZE) : 0;
|
||||
|
||||
function startEdit(event: IntakeEvent) {
|
||||
setEditing(event);
|
||||
setEditScheduledFor(toDateTimeLocalValue(event.scheduledFor));
|
||||
setEditTakenAt(event.takenAt ? toDateTimeLocalValue(event.takenAt) : "");
|
||||
setEditSkipped(event.skipped);
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
if (updateMutation.isPending || deleteMutation.isPending) return;
|
||||
setEditing(null);
|
||||
setEditError(null);
|
||||
setEditDeleteDialogOpen(false);
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
if (createMutation.isPending) return;
|
||||
setCreating(false);
|
||||
setCreateError(null);
|
||||
}
|
||||
|
||||
async function deleteEditingEvent() {
|
||||
if (!editing) return;
|
||||
try {
|
||||
setEditError(null);
|
||||
await deleteMutation.mutateAsync(editing.id);
|
||||
closeEdit();
|
||||
} catch {
|
||||
setEditError(t("medications.deleteError"));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!editing) return;
|
||||
|
||||
const scheduledDate = new Date(editScheduledFor);
|
||||
if (Number.isNaN(scheduledDate.getTime())) {
|
||||
setEditError(t("medications.invalidTimestamp"));
|
||||
return;
|
||||
}
|
||||
|
||||
setEditError(null);
|
||||
updateMutation.mutate({
|
||||
eventId: editing.id,
|
||||
scheduledFor: dateTimeLocalToISO(editScheduledFor),
|
||||
takenAt:
|
||||
editSkipped || !editTakenAt ? null : dateTimeLocalToISO(editTakenAt),
|
||||
skipped: editSkipped,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const scheduledDate = new Date(createScheduledFor);
|
||||
if (Number.isNaN(scheduledDate.getTime())) {
|
||||
setCreateError(t("medications.invalidTimestamp"));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateError(null);
|
||||
createMutation.mutate({
|
||||
scheduledFor: dateTimeLocalToISO(createScheduledFor),
|
||||
takenAt:
|
||||
createSkipped || !createTakenAt
|
||||
? null
|
||||
: dateTimeLocalToISO(createTakenAt),
|
||||
skipped: createSkipped,
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusBadge(event: IntakeEvent) {
|
||||
if (event.skipped) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground gap-1">
|
||||
<SkipForward className="h-3 w-3" />
|
||||
{t("medications.intakeStatusSkipped")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (event.takenAt) {
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="gap-1 bg-green-500/20 text-green-400"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
{t("medications.intakeStatusTaken")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1 bg-red-500/20 text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t("medications.intakeStatusMissed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.loginRequiredIntakes")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-end">
|
||||
{data?.meta?.total !== undefined && (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("medications.intakeCount", { count: data.meta.total })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="text-primary h-6 w-6 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
) : !data?.events?.length ? (
|
||||
<div className="text-muted-foreground flex h-32 items-center justify-center rounded-lg border border-dashed">
|
||||
{t("medications.noIntakesYet")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="bg-card border-border hidden overflow-hidden rounded-lg border md:block">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableHead
|
||||
column="scheduledFor"
|
||||
label={t("medications.intakeScheduledFor")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableHead
|
||||
column="takenAt"
|
||||
label={t("medications.intakeTakenAt")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<TableHead className="w-32">
|
||||
{t("medications.intakeStatus")}
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
column="source"
|
||||
label={t("medications.intakeSource")}
|
||||
currentSort={sortBy}
|
||||
currentDir={sortDir}
|
||||
onSort={toggleSort}
|
||||
className="w-28"
|
||||
/>
|
||||
<TableHead className="w-20 pr-4 text-right">
|
||||
{t("medications.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.events.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="text-sm tabular-nums">
|
||||
{formatDateTime(event.scheduledFor)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm tabular-nums">
|
||||
{event.takenAt ? formatDateTime(event.takenAt) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(event)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="pr-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
onClick={() => startEdit(event)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
label={t("medications.intakeDeleteConfirm")}
|
||||
description={t(
|
||||
"medications.intakeDeleteDescription",
|
||||
)}
|
||||
onConfirm={() => deleteMutation.mutate(event.id)}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Mobile cards */}
|
||||
<div className="space-y-2 md:hidden">
|
||||
{data.events.map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="bg-card border-border rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="text-sm font-medium tabular-nums">
|
||||
{formatDateTime(event.scheduledFor)}
|
||||
</p>
|
||||
{event.takenAt && (
|
||||
<p className="text-muted-foreground text-xs tabular-nums">
|
||||
{t("medications.intakeTakenAt")}:{" "}
|
||||
{formatDateTime(event.takenAt)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getStatusBadge(event)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{sourceLabels[event.source] ?? event.source}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
onClick={() => startEdit(event)}
|
||||
aria-label={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<DeleteButton
|
||||
label={t("medications.intakeDeleteConfirm")}
|
||||
description={t("medications.intakeDeleteDescription")}
|
||||
onConfirm={() => deleteMutation.mutate(event.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{t("medications.pageInfo", { page, total: totalPages })}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label={t("medications.previousPage")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label={t("medications.nextPage")}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Sheet */}
|
||||
<ResponsiveSheet
|
||||
open={!!editing}
|
||||
onOpenChange={(open) => !open && closeEdit()}
|
||||
title={t("medications.editIntake")}
|
||||
footer={<div ref={setEditFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{editing && (
|
||||
<form
|
||||
id={editFormId}
|
||||
onSubmit={submitEdit}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-scheduledFor">
|
||||
{t("medications.intakeScheduledFor")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="edit-scheduledFor"
|
||||
value={editScheduledFor}
|
||||
onChange={(e) => setEditScheduledFor(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!editError || undefined}
|
||||
aria-describedby={editErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-takenAt">
|
||||
{t("medications.intakeTakenAt")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="edit-takenAt"
|
||||
value={editTakenAt}
|
||||
onChange={(e) => setEditTakenAt(e.target.value)}
|
||||
disabled={editSkipped}
|
||||
aria-invalid={!!editError || undefined}
|
||||
aria-describedby={editErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="edit-skipped"
|
||||
checked={editSkipped}
|
||||
onCheckedChange={(checked) => {
|
||||
setEditSkipped(checked);
|
||||
if (checked) setEditTakenAt("");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="edit-skipped">
|
||||
{t("medications.intakeStatusSkipped")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<div
|
||||
id={editErrorId}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-11"
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setEditDeleteDialogOpen(true)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeEdit}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={editFormId}
|
||||
disabled={
|
||||
updateMutation.isPending ||
|
||||
deleteMutation.isPending
|
||||
}
|
||||
>
|
||||
{updateMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
editFooterEl,
|
||||
)
|
||||
: null}
|
||||
|
||||
<AlertDialog
|
||||
open={editDeleteDialogOpen}
|
||||
onOpenChange={setEditDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("medications.intakeDeleteConfirm")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("medications.intakeDeleteDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
{t("common.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={deleteEditingEvent}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</form>
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{/* Create Sheet */}
|
||||
<ResponsiveSheet
|
||||
open={creating}
|
||||
onOpenChange={(open) => !open && closeCreate()}
|
||||
title={t("medications.newIntake")}
|
||||
footer={<div ref={setCreateFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form
|
||||
id={createFormId}
|
||||
onSubmit={submitCreate}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-scheduledFor">
|
||||
{t("medications.intakeScheduledFor")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="create-scheduledFor"
|
||||
value={createScheduledFor}
|
||||
onChange={(e) => setCreateScheduledFor(e.target.value)}
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!createError || undefined}
|
||||
aria-describedby={createErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="create-takenAt">
|
||||
{t("medications.intakeTakenAt")}
|
||||
</Label>
|
||||
<DateTimeInput
|
||||
id="create-takenAt"
|
||||
value={createTakenAt}
|
||||
onChange={(e) => setCreateTakenAt(e.target.value)}
|
||||
disabled={createSkipped}
|
||||
aria-invalid={!!createError || undefined}
|
||||
aria-describedby={createErrorDescriptor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="create-skipped"
|
||||
checked={createSkipped}
|
||||
onCheckedChange={(checked) => {
|
||||
setCreateSkipped(checked);
|
||||
if (checked) setCreateTakenAt("");
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="create-skipped">
|
||||
{t("medications.intakeStatusSkipped")}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<div
|
||||
id={createErrorId}
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm"
|
||||
>
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</form>
|
||||
{createFooterEl
|
||||
? createPortal(
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={closeCreate}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={createFormId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : null}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>,
|
||||
createFooterEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableHead({
|
||||
column,
|
||||
label,
|
||||
currentSort,
|
||||
currentDir,
|
||||
onSort,
|
||||
className,
|
||||
}: {
|
||||
column: string;
|
||||
label: string;
|
||||
currentSort: string;
|
||||
currentDir: "asc" | "desc";
|
||||
onSort: (col: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const isActive = currentSort === column;
|
||||
return (
|
||||
<TableHead className={className}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSort(column)}
|
||||
className="hover:text-foreground inline-flex items-center gap-1 transition-colors"
|
||||
>
|
||||
{label}
|
||||
{isActive ? (
|
||||
currentDir === "asc" ? (
|
||||
<ArrowUp className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-3.5 w-3.5 opacity-40" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteButton({
|
||||
label,
|
||||
description,
|
||||
onConfirm,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive size-11"
|
||||
aria-label={label}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{label}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user