mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(medications): list customization moves into its own settings section
/settings/medications now hosts the view preference (cards or table) and the manual-order editor inline, following the dashboard and insights pattern. The page header's gear icon links there instead of opening a dialog; the quick view toggle stays next to the add button. The order editor keeps its grouped active/inactive semantics — a saved order can never interleave the groups.
This commit is contained in:
1 parent
5142823b7e
commit
02642a964e
17 files changed
+441
-180
No files matched your search
+7
-1
@@ -1718,6 +1718,7 @@
|
||||
"allowedSitesLabel": "Erlaubte Stellen",
|
||||
"allowedSitesHint": "Alle ausgewählt lassen für keine Einschränkung. Global ausgeschlossene Stellen werden nie angeboten.",
|
||||
"viewToggleLabel": "Ansicht",
|
||||
"viewToggleHint": "Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.",
|
||||
"viewCards": "Kartenansicht",
|
||||
"viewTable": "Tabellenansicht",
|
||||
"viewSaveFailed": "Ansicht konnte nicht gespeichert werden",
|
||||
@@ -1737,7 +1738,8 @@
|
||||
"reorderTitle": "Reihenfolge anpassen",
|
||||
"reorderDescription": "Bestimme die Reihenfolge deiner Medikamente – sie gilt für die Karten- und die Tabellenansicht.",
|
||||
"reorderSaved": "Reihenfolge gespeichert",
|
||||
"reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden"
|
||||
"reorderSaveFailed": "Reihenfolge konnte nicht gespeichert werden",
|
||||
"customize": "Medikamente anpassen"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Diagramm wird geladen…",
|
||||
@@ -3767,6 +3769,10 @@
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"description": "Reihenfolge der Übersicht und der Navigations-Pills."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medikamente",
|
||||
"description": "Listenansicht und Reihenfolge der Medikamente."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
|
||||
+7
-1
@@ -1718,6 +1718,7 @@
|
||||
"allowedSitesLabel": "Allowed sites",
|
||||
"allowedSitesHint": "Leave all selected for no restriction. Sites you excluded globally are never offered.",
|
||||
"viewToggleLabel": "View",
|
||||
"viewToggleHint": "Choose whether your medications appear as cards or as a table.",
|
||||
"viewCards": "Card view",
|
||||
"viewTable": "Table view",
|
||||
"viewSaveFailed": "View could not be saved",
|
||||
@@ -1737,7 +1738,8 @@
|
||||
"reorderTitle": "Adjust order",
|
||||
"reorderDescription": "Set the order of your medications – it applies to both the card and the table view.",
|
||||
"reorderSaved": "Order saved",
|
||||
"reorderSaveFailed": "Order could not be saved"
|
||||
"reorderSaveFailed": "Order could not be saved",
|
||||
"customize": "Customize medications"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Loading chart…",
|
||||
@@ -3767,6 +3769,10 @@
|
||||
"insights": {
|
||||
"title": "Insights",
|
||||
"description": "Overview and navigation-pill order."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medications",
|
||||
"description": "List view and medication order."
|
||||
}
|
||||
},
|
||||
"profile": "Profile",
|
||||
|
||||
+7
-1
@@ -1718,6 +1718,7 @@
|
||||
"allowedSitesLabel": "Sitios permitidos",
|
||||
"allowedSitesHint": "Deja todos seleccionados para no aplicar restricciones. Los sitios que excluiste globalmente nunca se ofrecen.",
|
||||
"viewToggleLabel": "Vista",
|
||||
"viewToggleHint": "Elige si tus medicamentos se muestran como tarjetas o como tabla.",
|
||||
"viewCards": "Vista de tarjetas",
|
||||
"viewTable": "Vista de tabla",
|
||||
"viewSaveFailed": "No se pudo guardar la vista",
|
||||
@@ -1737,7 +1738,8 @@
|
||||
"reorderTitle": "Ajustar orden",
|
||||
"reorderDescription": "Define el orden de tus medicamentos: se aplica a la vista de tarjetas y a la de tabla.",
|
||||
"reorderSaved": "Orden guardado",
|
||||
"reorderSaveFailed": "No se pudo guardar el orden"
|
||||
"reorderSaveFailed": "No se pudo guardar el orden",
|
||||
"customize": "Personalizar medicamentos"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Cargando la gráfica…",
|
||||
@@ -3767,6 +3769,10 @@
|
||||
"insights": {
|
||||
"title": "Análisis",
|
||||
"description": "Orden de la vista general y de las pestañas de navegación."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Medicamentos",
|
||||
"description": "Vista de lista y orden de los medicamentos."
|
||||
}
|
||||
},
|
||||
"profile": "Perfil",
|
||||
|
||||
+7
-1
@@ -1718,6 +1718,7 @@
|
||||
"allowedSitesLabel": "Sites autorisés",
|
||||
"allowedSitesHint": "Laissez tout sélectionné pour aucune restriction. Les sites exclus globalement ne sont jamais proposés.",
|
||||
"viewToggleLabel": "Affichage",
|
||||
"viewToggleHint": "Choisis si tes médicaments s'affichent sous forme de cartes ou de tableau.",
|
||||
"viewCards": "Vue cartes",
|
||||
"viewTable": "Vue tableau",
|
||||
"viewSaveFailed": "L'affichage n'a pas pu être enregistré",
|
||||
@@ -1737,7 +1738,8 @@
|
||||
"reorderTitle": "Modifier l'ordre",
|
||||
"reorderDescription": "Définis l'ordre de tes médicaments – il s'applique à la vue cartes et à la vue tableau.",
|
||||
"reorderSaved": "Ordre enregistré",
|
||||
"reorderSaveFailed": "L'ordre n'a pas pu être enregistré"
|
||||
"reorderSaveFailed": "L'ordre n'a pas pu être enregistré",
|
||||
"customize": "Personnaliser les médicaments"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Chargement du graphique…",
|
||||
@@ -3767,6 +3769,10 @@
|
||||
"insights": {
|
||||
"title": "Analyses",
|
||||
"description": "Ordre de la vue d'ensemble et des onglets de navigation."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Médicaments",
|
||||
"description": "Vue de la liste et ordre des médicaments."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
|
||||
+7
-1
@@ -1718,6 +1718,7 @@
|
||||
"allowedSitesLabel": "Siti consentiti",
|
||||
"allowedSitesHint": "Lascia tutti selezionati per nessuna restrizione. I siti esclusi globalmente non vengono mai proposti.",
|
||||
"viewToggleLabel": "Vista",
|
||||
"viewToggleHint": "Scegli se i tuoi farmaci vengono mostrati come schede o come tabella.",
|
||||
"viewCards": "Vista a schede",
|
||||
"viewTable": "Vista tabella",
|
||||
"viewSaveFailed": "Impossibile salvare la vista",
|
||||
@@ -1737,7 +1738,8 @@
|
||||
"reorderTitle": "Modifica ordine",
|
||||
"reorderDescription": "Imposta l'ordine dei tuoi farmaci: vale per la vista a schede e per la tabella.",
|
||||
"reorderSaved": "Ordine salvato",
|
||||
"reorderSaveFailed": "Impossibile salvare l’ordine"
|
||||
"reorderSaveFailed": "Impossibile salvare l’ordine",
|
||||
"customize": "Personalizza i farmaci"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Caricamento del grafico…",
|
||||
@@ -3767,6 +3769,10 @@
|
||||
"insights": {
|
||||
"title": "Approfondimenti",
|
||||
"description": "Ordine della panoramica e delle schede di navigazione."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Farmaci",
|
||||
"description": "Vista elenco e ordine dei farmaci."
|
||||
}
|
||||
},
|
||||
"profile": "Profilo",
|
||||
|
||||
+7
-1
@@ -1718,6 +1718,7 @@
|
||||
"allowedSitesLabel": "Dozwolone miejsca",
|
||||
"allowedSitesHint": "Pozostaw wszystkie zaznaczone, aby nie stosować ograniczeń. Miejsca wykluczone globalnie nigdy nie są proponowane.",
|
||||
"viewToggleLabel": "Widok",
|
||||
"viewToggleHint": "Wybierz, czy leki mają być wyświetlane jako karty, czy jako tabela.",
|
||||
"viewCards": "Widok kart",
|
||||
"viewTable": "Widok tabeli",
|
||||
"viewSaveFailed": "Nie udało się zapisać widoku",
|
||||
@@ -1737,7 +1738,8 @@
|
||||
"reorderTitle": "Zmień kolejność",
|
||||
"reorderDescription": "Ustal kolejność swoich leków – obowiązuje w widoku kart i w tabeli.",
|
||||
"reorderSaved": "Kolejność zapisana",
|
||||
"reorderSaveFailed": "Nie udało się zapisać kolejności"
|
||||
"reorderSaveFailed": "Nie udało się zapisać kolejności",
|
||||
"customize": "Dostosuj leki"
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Ładowanie wykresu…",
|
||||
@@ -3767,6 +3769,10 @@
|
||||
"insights": {
|
||||
"title": "Analizy",
|
||||
"description": "Kolejność przeglądu i pigułek nawigacyjnych."
|
||||
},
|
||||
"medications": {
|
||||
"title": "Leki",
|
||||
"description": "Widok listy i kolejność leków."
|
||||
}
|
||||
},
|
||||
"profile": "Profil",
|
||||
|
||||
@@ -33,9 +33,6 @@ vi.mock("@/components/medications/wizard/MedicationWizardDialog", () => ({
|
||||
vi.mock("@/components/medications/log-intake-dialog", () => ({
|
||||
LogIntakeDialog: () => null,
|
||||
}));
|
||||
vi.mock("@/components/medications/medication-reorder-dialog", () => ({
|
||||
MedicationReorderDialog: () => null,
|
||||
}));
|
||||
|
||||
import MedicationsPage from "@/app/medications/page";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
@@ -133,9 +130,15 @@ describe("/medications — view renders from the persisted preference", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("offers the manual-order editor from the header", () => {
|
||||
it("links the header customize glyph to /settings/medications", () => {
|
||||
// v1.16.10 — same entry-point logic as the dashboard and insights
|
||||
// headers: the Settings2 ghost icon is a plain link to the page's
|
||||
// settings section (view preference + manual order live there).
|
||||
const html = renderPage({ version: 1, view: "cards", order: [] });
|
||||
expect(html).toContain('aria-label="Adjust order"');
|
||||
expect(html).toContain('href="/settings/medications"');
|
||||
expect(html).toContain('aria-label="Customize medications"');
|
||||
// The in-page order dialog is gone — no dialog trigger remains.
|
||||
expect(html).not.toContain('aria-label="Adjust order"');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
MedicationTableSkeleton,
|
||||
} from "@/components/medications/medication-table";
|
||||
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
|
||||
import { MedicationReorderDialog } from "@/components/medications/medication-reorder-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
@@ -149,16 +149,13 @@ export default function MedicationsPage() {
|
||||
// two paths: log an intake (incl. a backdated one) against an existing
|
||||
// medication, or create a new medication (the existing wizard).
|
||||
const [logIntakeOpen, setLogIntakeOpen] = useState(false);
|
||||
// v1.16.10 — manual-order editor (shared by both views).
|
||||
const [reorderOpen, setReorderOpen] = useState(false);
|
||||
|
||||
// v1.16.10 — the persisted list presentation: cards vs table plus the
|
||||
// manual medication order, server-side per user
|
||||
// (`GET`/`PUT /api/medications/layout`). The toggle writes
|
||||
// optimistically; the reorder dialog saves explicitly.
|
||||
const { layout, isLayoutLoading, setView } = useMedicationListLayout(
|
||||
isAuthenticated,
|
||||
);
|
||||
// optimistically; the order editor lives at /settings/medications.
|
||||
const { layout, isLayoutLoading, setView } =
|
||||
useMedicationListLayout(isAuthenticated);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
@@ -268,25 +265,27 @@ export default function MedicationsPage() {
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{/* v1.16.10 — card ⇄ table view toggle, persisted server-side
|
||||
per user; plus the manual-order editor shared by both
|
||||
views. The editor opens behind the same Settings2 glyph
|
||||
the dashboard and insights headers use for their
|
||||
customize entry points, in the same slot (left of the
|
||||
add button). It only earns its slot once there is
|
||||
something to reorder. */}
|
||||
per user. The Settings2 glyph next to it is the customize
|
||||
shortcut to /settings/medications (view preference + the
|
||||
manual-order editor), exactly like the dashboard and
|
||||
insights headers link to their settings sections, in the
|
||||
same slot (left of the add button) and with the same
|
||||
responsive 44-px mobile tap floor. */}
|
||||
<MedicationViewToggle view={layout.view} onChange={setView} />
|
||||
{medsArray.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => setReorderOpen(true)}
|
||||
aria-label={t("medications.reorderTitle")}
|
||||
title={t("medications.reorderTitle")}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/medications"
|
||||
aria-label={t("medications.customize")}
|
||||
title={t("medications.customize")}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Settings2 className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
{/* v1.14.0 — the "Add" button is now a choice: log an intake
|
||||
(incl. a backdated one) against an existing medication, or
|
||||
create a new medication. v1.12.2 — match the dashboard Add
|
||||
@@ -324,11 +323,7 @@ export default function MedicationsPage() {
|
||||
// persisted view (table rows vs card grid) so resolving the data
|
||||
// never swaps the footprint; while the view preference itself is
|
||||
// still loading the default cards skeleton stands in.
|
||||
<div
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-label={t("medications.title")}
|
||||
>
|
||||
<div role="status" aria-busy="true" aria-label={t("medications.title")}>
|
||||
{tableView && !isLayoutLoading ? (
|
||||
<MedicationTableSkeleton />
|
||||
) : (
|
||||
@@ -462,22 +457,6 @@ export default function MedicationsPage() {
|
||||
date+time that supports backdating. Submits to the existing
|
||||
per-medication intake route (same slot upsert as a live "Taken"
|
||||
tap) so the one-row-per-dose-slot invariant holds. */}
|
||||
{/* v1.16.10 — manual-order editor. The list is the page's current
|
||||
effective order (active block first, inactive after), so the
|
||||
dialog opens showing exactly what both views render. */}
|
||||
{reorderOpen && (
|
||||
<MedicationReorderDialog
|
||||
open={reorderOpen}
|
||||
onOpenChange={setReorderOpen}
|
||||
medications={[...activeMeds, ...inactiveMeds].map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
dose: m.dose,
|
||||
active: m.active,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{logIntakeOpen && (
|
||||
<LogIntakeDialog
|
||||
open={logIntakeOpen}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DashboardSection } from "@/components/settings/dashboard-section";
|
||||
import { ExportSection } from "@/components/settings/export-section";
|
||||
import { InsightsSection } from "@/components/settings/insights-section";
|
||||
import { IntegrationsSection } from "@/components/settings/integrations-section";
|
||||
import { MedicationsSection } from "@/components/settings/medications-section";
|
||||
import { NotificationsSection } from "@/components/settings/notifications-section";
|
||||
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
|
||||
// v1.8.7.1 — `thresholds` (Targets) and `sources` (Sources) are two
|
||||
@@ -51,6 +52,7 @@ const SECTION_COMPONENTS: Record<
|
||||
notifications: NotificationsSection,
|
||||
dashboard: DashboardSection,
|
||||
insights: InsightsSection,
|
||||
medications: MedicationsSection,
|
||||
thresholds: ThresholdsSection,
|
||||
sources: () => <SourcesSection />,
|
||||
api: ApiSection,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/**
|
||||
* v1.16.10 — reorder dialog mirrors the page's grouping.
|
||||
* v1.16.10 — the inline manual-order editor mirrors the page's grouping.
|
||||
*
|
||||
* Both /medications views pin inactive medications after the active
|
||||
* block, so the dialog drafts TWO sections (Aktiv / Inaktiv) with a
|
||||
* block, so the editor drafts TWO sections (Aktiv / Inaktiv) with a
|
||||
* muted heading each: drag and arrows work within a section, and the
|
||||
* saved order is active ids first, inactive ids after — by
|
||||
* construction, not by validation.
|
||||
* construction, not by validation. The editor renders inline in the
|
||||
* Medikamente settings section (`/settings/medications`); it started
|
||||
* life as a dialog on the /medications page and kept its contract when
|
||||
* it moved.
|
||||
*
|
||||
* Project convention is SSR-only component tests (`renderToStaticMarkup`)
|
||||
* plus pure-helper tests for the interactive plumbing an SSR mount
|
||||
@@ -18,24 +21,6 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
// Radix Dialog portals at runtime; collapse to plain wrappers so the
|
||||
// body materialises in static markup (same trick as the sibling suites).
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const Pass = ({ children }: { children?: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
);
|
||||
return {
|
||||
Dialog: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-slot="mock-dialog">{children}</div>
|
||||
),
|
||||
DialogContent: Pass,
|
||||
DialogDescription: Pass,
|
||||
DialogFooter: Pass,
|
||||
DialogHeader: Pass,
|
||||
DialogTitle: Pass,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
}));
|
||||
@@ -48,8 +33,8 @@ vi.mock("@/lib/queries/use-medication-list-layout", () => ({
|
||||
runSaveMedicationListOrder: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { MedicationReorderDialog, buildSavedOrder, moveWithinSection } =
|
||||
await import("../medication-reorder-dialog");
|
||||
const { MedicationOrderEditor, buildSavedOrder, moveWithinSection } =
|
||||
await import("../medication-order-editor");
|
||||
|
||||
function render(node: React.ReactNode): string {
|
||||
return renderToStaticMarkup(
|
||||
@@ -90,31 +75,23 @@ describe("buildSavedOrder — active ids always precede inactive ids", () => {
|
||||
});
|
||||
|
||||
it("an inactive med can never land above an active one in the SAVED order", () => {
|
||||
// Drive the dialog's own move primitive as far up as it allows and
|
||||
// Drive the editor's own move primitive as far up as it allows and
|
||||
// compose the save payload exactly like save() does.
|
||||
let inactive = ["i1", "i2"];
|
||||
inactive = moveWithinSection(inactive, "i2", -1); // i2 to the top of its group
|
||||
inactive = moveWithinSection(inactive, "i2", -1); // edge — no-op
|
||||
const saved = buildSavedOrder(["a1", "a2"], inactive);
|
||||
expect(saved).toEqual(["a1", "a2", "i2", "i1"]);
|
||||
const firstInactiveIdx = Math.min(
|
||||
saved.indexOf("i1"),
|
||||
saved.indexOf("i2"),
|
||||
);
|
||||
const firstInactiveIdx = Math.min(saved.indexOf("i1"), saved.indexOf("i2"));
|
||||
const lastActiveIdx = Math.max(saved.indexOf("a1"), saved.indexOf("a2"));
|
||||
expect(firstInactiveIdx).toBeGreaterThan(lastActiveIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("<MedicationReorderDialog> — two-section rendering", () => {
|
||||
describe("<MedicationOrderEditor> — two-section rendering", () => {
|
||||
it("renders an Aktiv and an Inaktiv section with the rows grouped", () => {
|
||||
const html = render(
|
||||
<MedicationReorderDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
medications={MEDS}
|
||||
/>,
|
||||
);
|
||||
const html = render(<MedicationOrderEditor medications={MEDS} />);
|
||||
expect(html).toContain('data-slot="medication-order-editor"');
|
||||
expect(html).toContain('data-slot="medication-reorder-section-active"');
|
||||
expect(html).toContain('data-slot="medication-reorder-section-inactive"');
|
||||
// Muted group headings.
|
||||
@@ -134,24 +111,26 @@ describe("<MedicationReorderDialog> — two-section rendering", () => {
|
||||
|
||||
it("omits the Inaktiv section when every medication is active", () => {
|
||||
const html = render(
|
||||
<MedicationReorderDialog
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
medications={MEDS.filter((m) => m.active)}
|
||||
/>,
|
||||
<MedicationOrderEditor medications={MEDS.filter((m) => m.active)} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="medication-reorder-section-active"');
|
||||
expect(html).not.toContain(
|
||||
'data-slot="medication-reorder-section-inactive"',
|
||||
);
|
||||
});
|
||||
|
||||
it("hides the Save / Cancel pair until a draft exists (clean editor = no footer)", () => {
|
||||
const html = render(<MedicationOrderEditor medications={MEDS} />);
|
||||
expect(html).not.toContain(">Speichern<");
|
||||
expect(html).not.toContain(">Abbrechen<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication-reorder-dialog — structural guards (source)", () => {
|
||||
describe("medication-order-editor — structural guards (source)", () => {
|
||||
const src = readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
"src/components/medications/medication-reorder-dialog.tsx",
|
||||
"src/components/medications/medication-order-editor.tsx",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
@@ -162,11 +141,11 @@ describe("medication-reorder-dialog — structural guards (source)", () => {
|
||||
|
||||
it("each section owns its own DndContext, so a drag cannot cross groups", () => {
|
||||
// One <DndContext> inside the per-section component, none at the
|
||||
// dialog level spanning both lists.
|
||||
// editor level spanning both lists.
|
||||
const sectionBody = src.slice(src.indexOf("function ReorderSection"));
|
||||
expect(sectionBody).toContain("<DndContext");
|
||||
expect(
|
||||
src.slice(0, src.indexOf("function ReorderSection")),
|
||||
).not.toContain("<DndContext");
|
||||
expect(src.slice(0, src.indexOf("function ReorderSection"))).not.toContain(
|
||||
"<DndContext",
|
||||
);
|
||||
});
|
||||
});
|
||||
+64
-77
@@ -20,14 +20,6 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reorderById } from "@/lib/insights-layout-reorder";
|
||||
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
|
||||
@@ -44,14 +36,21 @@ import { runSaveMedicationListOrder } from "@/lib/queries/use-medication-list-la
|
||||
* mode rides (the standalone extraction of the dashboard's
|
||||
* `reorderWidgets` contract), so the surfaces cannot drift.
|
||||
*
|
||||
* The dialog mirrors the page's grouping: an Aktiv section and an
|
||||
* Hosted inline by the Medikamente settings section
|
||||
* (`/settings/medications`), exactly like the dashboard layout editor
|
||||
* lives under `/settings/dashboard` — the editor used to be a dialog on
|
||||
* the /medications page; the page header now links here instead.
|
||||
*
|
||||
* The editor mirrors the page's grouping: an Aktiv section and an
|
||||
* Inaktiv section (muted heading), each with its own drag context and
|
||||
* arrow bounds, so a row can only move WITHIN its group — both views
|
||||
* pin inactive medications after the active block, and an order that
|
||||
* crossed the boundary could never render. Save persists the id order
|
||||
* (active ids first, then inactive ids) through
|
||||
* `PUT /api/medications/layout` (order-only, the stored view is
|
||||
* preserved server-side).
|
||||
* preserved server-side). The Save / Cancel pair only appears once a
|
||||
* draft exists, following the dashboard layout section's draft state
|
||||
* machine.
|
||||
*/
|
||||
|
||||
export interface ReorderMedication {
|
||||
@@ -61,9 +60,7 @@ export interface ReorderMedication {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface MedicationReorderDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
interface MedicationOrderEditorProps {
|
||||
/** The full list in its current effective order (active block first). */
|
||||
medications: ReorderMedication[];
|
||||
}
|
||||
@@ -100,28 +97,24 @@ export function moveWithinSection(
|
||||
).map((r) => r.id);
|
||||
}
|
||||
|
||||
export function MedicationReorderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
export function MedicationOrderEditor({
|
||||
medications,
|
||||
}: MedicationReorderDialogProps) {
|
||||
}: MedicationOrderEditorProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const dragHintId = useId();
|
||||
const [saving, setSaving] = useState(false);
|
||||
// Per-section draft id order — null means "the order the page passed
|
||||
// in". Created on the first move so an untouched dialog can cancel
|
||||
// without churn.
|
||||
// Per-section draft id order — null means "the order the host passed
|
||||
// in". Created on the first move so an untouched editor renders the
|
||||
// persisted order without churn; Cancel simply clears the drafts.
|
||||
const [draftActive, setDraftActive] = useState<string[] | null>(null);
|
||||
const [draftInactive, setDraftInactive] = useState<string[] | null>(null);
|
||||
|
||||
const byId = new Map(medications.map((m) => [m.id, m]));
|
||||
const activeIds =
|
||||
draftActive ??
|
||||
medications.filter((m) => m.active).map((m) => m.id);
|
||||
draftActive ?? medications.filter((m) => m.active).map((m) => m.id);
|
||||
const inactiveIds =
|
||||
draftInactive ??
|
||||
medications.filter((m) => !m.active).map((m) => m.id);
|
||||
draftInactive ?? medications.filter((m) => !m.active).map((m) => m.id);
|
||||
|
||||
const labels = {
|
||||
moveUp: t("dashboard.moveUp"),
|
||||
@@ -141,79 +134,73 @@ export function MedicationReorderDialog({
|
||||
if (ok) {
|
||||
setDraftActive(null);
|
||||
setDraftInactive(null);
|
||||
onOpenChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
function close(next: boolean) {
|
||||
if (!next) {
|
||||
setDraftActive(null);
|
||||
setDraftInactive(null);
|
||||
}
|
||||
onOpenChange(next);
|
||||
function cancel() {
|
||||
setDraftActive(null);
|
||||
setDraftInactive(null);
|
||||
}
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draftActive !== null || draftInactive !== null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={close}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.reorderTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("medications.reorderDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4" data-slot="medication-order-editor">
|
||||
{medications.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.emptyTitle")}
|
||||
</p>
|
||||
)}
|
||||
{activeIds.length > 0 && (
|
||||
<ReorderSection
|
||||
heading={t("common.active")}
|
||||
ids={activeIds}
|
||||
byId={byId}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saving}
|
||||
labels={labels}
|
||||
onChange={setDraftActive}
|
||||
dataSlot="medication-reorder-section-active"
|
||||
/>
|
||||
)}
|
||||
{inactiveIds.length > 0 && (
|
||||
<ReorderSection
|
||||
heading={t("common.inactive")}
|
||||
ids={inactiveIds}
|
||||
byId={byId}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saving}
|
||||
labels={labels}
|
||||
onChange={setDraftInactive}
|
||||
dataSlot="medication-reorder-section-inactive"
|
||||
/>
|
||||
)}
|
||||
{activeIds.length + inactiveIds.length > 0 && (
|
||||
<p id={dragHintId} className="text-muted-foreground sr-only">
|
||||
{t("dashboard.dragHandleHint")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{activeIds.length > 0 && (
|
||||
<ReorderSection
|
||||
heading={t("common.active")}
|
||||
ids={activeIds}
|
||||
byId={byId}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saving}
|
||||
labels={labels}
|
||||
onChange={setDraftActive}
|
||||
dataSlot="medication-reorder-section-active"
|
||||
/>
|
||||
)}
|
||||
{inactiveIds.length > 0 && (
|
||||
<ReorderSection
|
||||
heading={t("common.inactive")}
|
||||
ids={inactiveIds}
|
||||
byId={byId}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saving}
|
||||
labels={labels}
|
||||
onChange={setDraftInactive}
|
||||
dataSlot="medication-reorder-section-inactive"
|
||||
/>
|
||||
)}
|
||||
{activeIds.length + inactiveIds.length > 0 && (
|
||||
<p id={dragHintId} className="text-muted-foreground sr-only">
|
||||
{t("dashboard.dragHandleHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{dirty && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => close(false)}
|
||||
size="sm"
|
||||
onClick={cancel}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={saving || !dirty}>
|
||||
<Button size="sm" onClick={() => void save()} disabled={saving}>
|
||||
{saving && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* v1.16.10 — the "Medikamente" settings section (`/settings/medications`).
|
||||
*
|
||||
* Hosts the /medications list customisation following the dashboard /
|
||||
* insights pattern: the cards ⇄ table view preference (the shared header
|
||||
* toggle, optimistic PUT) plus the manual-order editor INLINE (two
|
||||
* grouped sections, explicit Save). SSR smoke assertions, matching the
|
||||
* rest of the settings suite — the editor's interactive contract is
|
||||
* pinned in `medication-order-editor.test.tsx`, the PUT side in
|
||||
* `use-medication-list-layout.test.ts`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import type { MedicationListLayout } from "@/lib/medication-list-layout";
|
||||
|
||||
const MEDS = [
|
||||
{ id: "a1", name: "Ramipril", dose: "5 mg", active: true },
|
||||
{ id: "a2", name: "Aspirin", dose: "100 mg", active: true },
|
||||
{ id: "i1", name: "Amoxicillin", dose: "500 mg", active: false },
|
||||
];
|
||||
|
||||
// Mutable holder so individual tests can inject a layout into the mocked
|
||||
// `useQuery` without re-mocking the module.
|
||||
const queryState: { layout: MedicationListLayout } = {
|
||||
layout: { version: 1, view: "cards", order: [] },
|
||||
};
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: readonly unknown[] }) => {
|
||||
if (Array.isArray(queryKey) && queryKey[0] === "medication-list-layout") {
|
||||
return { data: queryState.layout, isLoading: false };
|
||||
}
|
||||
if (Array.isArray(queryKey) && queryKey[0] === "medications") {
|
||||
return { data: MEDS, isLoading: false };
|
||||
}
|
||||
return { data: null, isLoading: false };
|
||||
},
|
||||
useQueryClient: () => ({ setQueryData: vi.fn(), getQueryData: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/charts/reduced-motion", () => ({
|
||||
prefersReducedMotion: () => true,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { MedicationsSection } from "../medications-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en"): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<MedicationsSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
queryState.layout = { version: 1, view: "cards", order: [] };
|
||||
});
|
||||
|
||||
describe("<MedicationsSection> — SSR smoke", () => {
|
||||
it("renders the section heading + description via i18n (no raw keys)", () => {
|
||||
const html = render();
|
||||
expect(html).toContain("settings-section-medications-title");
|
||||
expect(html).toContain("List view and medication order.");
|
||||
expect(html).not.toContain("settings.sections.");
|
||||
expect(html).not.toContain("medications.view");
|
||||
expect(html).not.toContain("medications.reorder");
|
||||
});
|
||||
|
||||
it("renders the German copy end-to-end", () => {
|
||||
const html = render("de");
|
||||
expect(html).toContain("Listenansicht und Reihenfolge der Medikamente.");
|
||||
expect(html).toContain("Reihenfolge anpassen");
|
||||
expect(html).toContain(
|
||||
"Wähle, ob deine Medikamente als Karten oder als Tabelle angezeigt werden.",
|
||||
);
|
||||
});
|
||||
|
||||
it("hosts the view toggle bound to the persisted layout (cards pressed)", () => {
|
||||
const html = render();
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-cards"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-cards"/,
|
||||
);
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-table"[^>]*aria-pressed="false"|aria-pressed="false"[^>]*data-slot="medications-view-table"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("reflects a persisted table view on the toggle", () => {
|
||||
queryState.layout = { version: 1, view: "table", order: [] };
|
||||
const html = render();
|
||||
expect(html).toMatch(
|
||||
/data-slot="medications-view-table"[^>]*aria-pressed="true"|aria-pressed="true"[^>]*data-slot="medications-view-table"/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the order editor INLINE with active rows before inactive", () => {
|
||||
const html = render();
|
||||
expect(html).toContain('data-slot="medication-order-editor"');
|
||||
expect(html).toContain('data-slot="medication-reorder-section-active"');
|
||||
expect(html).toContain('data-slot="medication-reorder-section-inactive"');
|
||||
const lastActive = Math.max(
|
||||
html.indexOf("Ramipril"),
|
||||
html.indexOf("Aspirin"),
|
||||
);
|
||||
expect(lastActive).toBeLessThan(html.indexOf("Amoxicillin"));
|
||||
});
|
||||
|
||||
it("feeds the editor the saved order (active block re-sorted by layout.order)", () => {
|
||||
// Alphabetical default would put Aspirin first; the saved order pins
|
||||
// Ramipril to the top — the editor must open showing exactly what
|
||||
// both /medications views render.
|
||||
queryState.layout = { version: 1, view: "cards", order: ["a1", "a2"] };
|
||||
const html = render();
|
||||
expect(html.indexOf("Ramipril")).toBeLessThan(html.indexOf("Aspirin"));
|
||||
});
|
||||
});
|
||||
@@ -50,12 +50,16 @@ describe("SETTINGS_SECTION_SLUGS", () => {
|
||||
// between `thresholds` (Targets) and `ai`.
|
||||
// v1.15.18 — `insights` sits between `dashboard` and `thresholds`: the
|
||||
// overview-arrange + pill-sort customise surface for `/insights`.
|
||||
// v1.16.10 — `medications` sits between `insights` and `thresholds`:
|
||||
// the view-preference + manual-order customise surface for
|
||||
// `/medications`, reached from the page header's Settings2 glyph.
|
||||
expect([...SETTINGS_SECTION_SLUGS]).toEqual([
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"thresholds",
|
||||
"sources",
|
||||
"ai",
|
||||
@@ -149,6 +153,10 @@ describe("<SettingsShell>", () => {
|
||||
expect(html).toContain("Notifications");
|
||||
expect(html).toContain("Dashboard");
|
||||
expect(html).toContain("Insights");
|
||||
// v1.16.10 — the /medications customise surface is its own nav entry
|
||||
// between Insights and Targets.
|
||||
expect(html).toContain('href="/settings/medications"');
|
||||
expect(html).toContain("Medications");
|
||||
// The ampersand is HTML-escaped by React SSR — assert on the encoded
|
||||
// form so we don't accidentally match a parser that double-escapes.
|
||||
expect(html).toContain("API & Tokens");
|
||||
@@ -181,6 +189,9 @@ describe("<SettingsShell>", () => {
|
||||
// per-metric overrides moved out into their own "Persönliche Zielwerte"
|
||||
// section, which is the new entry below the Dashboard one.
|
||||
expect(html).toContain("Dashboard");
|
||||
// v1.16.10 — the /medications customise surface ("Medikamente").
|
||||
expect(html).toContain('href="/settings/medications"');
|
||||
expect(html).toContain("Medikamente");
|
||||
// v1.8.7.1 — Targets and Sources are two separate German nav
|
||||
// entries again: "Zielwerte" and "Quellen".
|
||||
expect(html).toContain("Zielwerte");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { LayoutGrid, Loader2, Pill } from "lucide-react";
|
||||
|
||||
import {
|
||||
MedicationOrderEditor,
|
||||
type ReorderMedication,
|
||||
} from "@/components/medications/medication-order-editor";
|
||||
import { MedicationViewToggle } from "@/components/medications/medication-view-toggle";
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { applyMedicationOrder } from "@/lib/medications/medication-order";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { useMedicationListLayout } from "@/lib/queries/use-medication-list-layout";
|
||||
|
||||
/**
|
||||
* v1.16.10 — the "Medikamente" settings section (analogous to the
|
||||
* Dashboard and Insights sections). Hosts two blocks:
|
||||
* 1. The list VIEW preference — cards vs table, written optimistically
|
||||
* through the same `PUT /api/medications/layout` the page header
|
||||
* toggle uses (the toggle component is shared).
|
||||
* 2. The manual ORDER editor — two grouped lists (Aktiv / Inaktiv)
|
||||
* with drag + arrow reordering, flushed by an explicit Save,
|
||||
* persisted as active-ids-then-inactive-ids on the same layout row.
|
||||
*
|
||||
* Both write through the same `/api/medications/layout` contract the
|
||||
* /medications page reads, so a save here repaints both list views.
|
||||
*/
|
||||
|
||||
/** The slice of the medications list the order editor needs. */
|
||||
interface MedicationListEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function MedicationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { layout, isLayoutLoading, setView } = useMedicationListLayout();
|
||||
|
||||
const { data: medications, isLoading } = useQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
queryFn: async () => {
|
||||
return apiGet<MedicationListEntry[]>("/api/medications");
|
||||
},
|
||||
});
|
||||
|
||||
// Defensive against stale service-worker responses or any future API
|
||||
// shape change: only map when we actually have an array. The editor
|
||||
// receives the page's current effective order (active block first,
|
||||
// inactive after) so it opens showing exactly what both views render.
|
||||
const medsArray = Array.isArray(medications) ? medications : [];
|
||||
const ordered: ReorderMedication[] = [
|
||||
...applyMedicationOrder(
|
||||
medsArray.filter((m) => m.active),
|
||||
layout.order,
|
||||
),
|
||||
...applyMedicationOrder(
|
||||
medsArray.filter((m) => !m.active),
|
||||
layout.order,
|
||||
),
|
||||
].map((m) => ({ id: m.id, name: m.name, dose: m.dose, active: m.active }));
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-section-medications-title"
|
||||
className="space-y-6"
|
||||
>
|
||||
<header className="space-y-1">
|
||||
<h1 id="settings-section-medications-title" className="sr-only">
|
||||
{t("settings.sections.medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("settings.sections.medications.description")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* View preference — cards vs table. The shared header toggle
|
||||
writes optimistically, so there is no Save button here. */}
|
||||
<div
|
||||
id="medications-view"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutGrid className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("medications.viewToggleLabel")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="border-border bg-background/30 flex min-h-12 items-center justify-between gap-3 rounded-md border px-3 py-2">
|
||||
<p className="text-muted-foreground min-w-0 text-xs">
|
||||
{t("medications.viewToggleHint")}
|
||||
</p>
|
||||
{isLayoutLoading ? (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<MedicationViewToggle view={layout.view} onChange={setView} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual order — applies to both list views. */}
|
||||
<div
|
||||
id="medications-order"
|
||||
className="bg-card border-border scroll-mt-28 space-y-4 rounded-xl border p-4 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill className="text-muted-foreground h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("medications.reorderTitle")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.reorderDescription")}
|
||||
</p>
|
||||
{isLoading || isLayoutLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<MedicationOrderEditor medications={ordered} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Pill,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
@@ -37,6 +38,7 @@ const SLUG_ICON: Record<SettingsSectionSlug, LucideIcon> = {
|
||||
notifications: Bell,
|
||||
dashboard: LayoutDashboard,
|
||||
insights: TrendingUp,
|
||||
medications: Pill,
|
||||
thresholds: SlidersHorizontal,
|
||||
sources: Layers,
|
||||
ai: Sparkles,
|
||||
|
||||
@@ -13,12 +13,17 @@
|
||||
// `/api/auth/me/source-priority`) and reading them as one page made the
|
||||
// surface long; splitting them back keeps each concern self-contained.
|
||||
// Both slugs are served by the dynamic `[section]` route below.
|
||||
// v1.16.10 — `medications` sits after `insights`: the customise surface
|
||||
// for the /medications list (view preference + manual order), following
|
||||
// the dashboard / insights pattern of a dedicated settings section
|
||||
// reached from the page header's Settings2 glyph.
|
||||
export const SETTINGS_SECTION_SLUGS = [
|
||||
"account",
|
||||
"integrations",
|
||||
"notifications",
|
||||
"dashboard",
|
||||
"insights",
|
||||
"medications",
|
||||
"thresholds",
|
||||
"sources",
|
||||
"ai",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Link2,
|
||||
Pill,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
@@ -100,6 +101,11 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [
|
||||
titleKey: "settings.sections.insights.title",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
slug: "medications",
|
||||
titleKey: "settings.sections.medications.title",
|
||||
icon: Pill,
|
||||
},
|
||||
{
|
||||
slug: "thresholds",
|
||||
titleKey: "settings.sections.thresholds.title",
|
||||
|
||||
Reference in new issue
Block a user