mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(meds): lift IntakeImportDialog out of the list page and bundle the inline-compliance prefix
The list page was carrying two large dialogs (`IntakeImportDialog` and `ApiEndpointDialog`) whose state slots had no list-card trigger wired to them. The slots were only ever set from inside the dialogs themselves, so they read as dead code on the list page even though the dialog bodies themselves were healthy. `IntakeImportDialog` lifts to `src/components/medications/intake-import-dialog.tsx` as the canonical mount the detail-page intake-history header will import. `ApiEndpointDialog` no longer mounts on the list page; its final v1.5.5 home is the detail-page Settings sub-row (lands in a later commit). The list page drops the two state slots, the inline dialog bodies, and the now-unused imports. `medicationDependentKeys` also gains the `compliance-chart-inline` prefix. The per-medication inline compliance chart used to mount under `queryKeys.medicationComplianceChart(id)` (which expands to `["compliance-chart-inline", id]`) but the bundle did not match the prefix, so an intake mutation left the chart up to 60 s stale. The prefix now lands in the bundle so every detail-page mutation evicts the tile in one tick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c97c4c3be3
commit
ef21967d12
4 files changed
+287
-602
No files matched your search
+12
-602
@@ -1,15 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
invalidateKeys,
|
||||
medicationDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import { MedicationWizardDialog } from "@/components/medications/wizard/MedicationWizardDialog";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
@@ -17,38 +13,7 @@ import { MedicationCard } from "@/components/medications/medication-card";
|
||||
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
Plus,
|
||||
Pill,
|
||||
Upload,
|
||||
Copy,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Loader2, Pill, Plus } from "lucide-react";
|
||||
|
||||
interface Schedule {
|
||||
id: string;
|
||||
@@ -102,11 +67,6 @@ export default function MedicationsPage() {
|
||||
const shouldOpenFromUrl = searchParams?.get("new") === "1";
|
||||
const [dialogOpen, setDialogOpen] = useState(shouldOpenFromUrl);
|
||||
const [editingMed, setEditingMed] = useState<Medication | null>(null);
|
||||
const [importMedId, setImportMedId] = useState<string | null>(null);
|
||||
const [apiMed, setApiMed] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldOpenFromUrl) {
|
||||
@@ -286,14 +246,15 @@ export default function MedicationsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Endpoint Dialog */}
|
||||
<ApiEndpointDialog medication={apiMed} onClose={() => setApiMed(null)} />
|
||||
|
||||
{/* Import Dialog */}
|
||||
<IntakeImportDialog
|
||||
medicationId={importMedId}
|
||||
onClose={() => setImportMedId(null)}
|
||||
/>
|
||||
{/* v1.5.5 — the per-card kebab triggers used to open inline
|
||||
IntakeImportDialog / ApiEndpointDialog mounts here, but both
|
||||
state slots were only ever set from inside the dialogs
|
||||
themselves (never from a list-card prop) so the inline
|
||||
mounts were dead code. v1.5.5 retires both on the list page:
|
||||
the import dialog lives in the detail-page intake-history
|
||||
header (see `src/components/medications/intake-import-dialog.tsx`),
|
||||
the API endpoint moves to the detail-page Settings → Externe
|
||||
Integration sub-row. */}
|
||||
|
||||
{/* v1.5.4 — modal-wizard mount. The same component drives both
|
||||
create (no initial) and edit (hydrates from the medication's
|
||||
@@ -341,554 +302,3 @@ function medicationToPayload(med: Medication): MedicationPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function IntakeImportDialog({
|
||||
medicationId,
|
||||
onClose,
|
||||
}: {
|
||||
medicationId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetImportForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
// Support both array and object-with-array
|
||||
if (!Array.isArray(data)) {
|
||||
const arrKey = Object.keys(data).find((k) => Array.isArray(data[k]));
|
||||
if (arrKey) data = data[arrKey];
|
||||
else throw new Error(t("medications.importNoArray"));
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
const d = json.data;
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
setResult(json.error || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
// JSON paste — disable sentence-case and spell-check so the
|
||||
// primitive defaults don't munge structured input.
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — icon swap (not append) on `importing`
|
||||
to keep the button width stable through the
|
||||
in-flight request. Pre-v1.4.33 both icons painted
|
||||
during loading and the button grew by ~24 px, which
|
||||
showed up as a CLS hit on the medications import
|
||||
dialog.
|
||||
*/}
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiEndpointDialog({
|
||||
medication,
|
||||
onClose,
|
||||
}: {
|
||||
medication: { id: string; name: string } | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
type ExampleType = "curl" | "wget" | "fetch" | "powershell";
|
||||
|
||||
const apiEndpointKey = ["medication-api-endpoint", medication?.id];
|
||||
|
||||
type ApiEndpointStatus = { enabled: boolean; activeTokenCount: number };
|
||||
|
||||
const {
|
||||
data: status,
|
||||
isFetching: loadingStatus,
|
||||
refetch: refetchStatus,
|
||||
error: statusError,
|
||||
} = useQuery<ApiEndpointStatus>({
|
||||
queryKey: apiEndpointKey,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication!.id}/api-endpoint`,
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(json.error || t("medications.statusLoadFailed"));
|
||||
}
|
||||
return {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount: json.data.activeTokenCount ?? 0,
|
||||
};
|
||||
},
|
||||
enabled: !!medication,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
|
||||
const enabled = status?.enabled ?? false;
|
||||
const activeTokenCount = status?.activeTokenCount ?? 0;
|
||||
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const [exampleType, setExampleType] = useState<ExampleType>("curl");
|
||||
|
||||
const displayMsg =
|
||||
msg ?? (statusError instanceof Error ? statusError.message : null);
|
||||
|
||||
function handleClose() {
|
||||
queryClient.removeQueries({ queryKey: apiEndpointKey });
|
||||
setToken(null);
|
||||
setMsg(null);
|
||||
setCopied(null);
|
||||
setExampleType("curl");
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function toggleEndpoint(nextEnabled: boolean) {
|
||||
if (!medication) return;
|
||||
setToggling(true);
|
||||
setToken(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/medications/${medication.id}/api-endpoint`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: nextEnabled }),
|
||||
},
|
||||
);
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
setMsg(json.error || t("medications.changeFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus: ApiEndpointStatus = {
|
||||
enabled: json.data.enabled === true,
|
||||
activeTokenCount:
|
||||
typeof json.data.activeTokenCount === "number"
|
||||
? json.data.activeTokenCount
|
||||
: !json.data.enabled
|
||||
? 0
|
||||
: (status?.activeTokenCount ?? 0),
|
||||
};
|
||||
queryClient.setQueryData<ApiEndpointStatus>(apiEndpointKey, nextStatus);
|
||||
|
||||
if (json.data.token) {
|
||||
setToken(json.data.token);
|
||||
}
|
||||
|
||||
setMsg(
|
||||
nextEnabled
|
||||
? t("medications.apiEndpointActivated")
|
||||
: t("medications.apiEndpointDeactivated"),
|
||||
);
|
||||
} catch {
|
||||
setMsg(t("medications.changeFailed"));
|
||||
} finally {
|
||||
setToggling(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, key: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
|
||||
const baseUrl =
|
||||
typeof window !== "undefined" ? window.location.origin : "https://...";
|
||||
const endpoint = `${baseUrl}/api/ingest/medication`;
|
||||
const payload = `{"medicationName":"${medication?.name ?? ""}","idempotencyKey":"einnahme-202602191230"}`;
|
||||
const curlCmd = `curl -X POST ${endpoint} \\
|
||||
-H "Authorization: Bearer ${token ?? "DEIN_TOKEN"}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '${payload}'`;
|
||||
const wgetCmd = `wget --method=POST "${endpoint}" \\
|
||||
--header="Authorization: Bearer ${token ?? "DEIN_TOKEN"}" \\
|
||||
--header="Content-Type: application/json" \\
|
||||
--body-data='${payload}' \\
|
||||
-O -`;
|
||||
const fetchCmd = `await fetch("${endpoint}", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": "Bearer ${token ?? "DEIN_TOKEN"}",
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
medicationName: "${medication?.name ?? ""}",
|
||||
idempotencyKey: "einnahme-" + Date.now()
|
||||
})
|
||||
});`;
|
||||
const powershellCmd = `Invoke-RestMethod -Method Post -Uri "${endpoint}" \\
|
||||
-Headers @{ Authorization = "Bearer ${token ?? "DEIN_TOKEN"}" } \\
|
||||
-ContentType "application/json" \\
|
||||
-Body '${payload}'`;
|
||||
|
||||
const exampleMap: Record<ExampleType, { label: string; value: string }> = {
|
||||
curl: { label: "cURL", value: curlCmd },
|
||||
wget: { label: "wget", value: wgetCmd },
|
||||
fetch: { label: "JavaScript fetch", value: fetchCmd },
|
||||
powershell: { label: "PowerShell", value: powershellCmd },
|
||||
};
|
||||
const selectedExample = exampleMap[exampleType];
|
||||
|
||||
return (
|
||||
<Dialog open={!!medication} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("medications.apiEndpointTitle", {
|
||||
name: medication?.name ?? "",
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.apiEndpointDescription")}
|
||||
</p>
|
||||
|
||||
<div className="bg-muted/40 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{t("medications.apiEndpointActive")}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("medications.apiTokenCount", {
|
||||
count: activeTokenCount,
|
||||
})
|
||||
: t("common.disabled")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
disabled={loadingStatus || toggling}
|
||||
onCheckedChange={(checked) => {
|
||||
void toggleEndpoint(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Endpoint</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded px-3 py-2 font-mono text-xs break-all">
|
||||
POST {endpoint}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(endpoint, "url")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "url" && (
|
||||
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("medications.apiToken")}
|
||||
</Label>
|
||||
{token ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="bg-muted flex-1 rounded px-3 py-2 font-mono text-xs break-all">
|
||||
{token}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => copyText(token, "token")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{enabled
|
||||
? t("medications.apiTokenActiveHint")
|
||||
: t("medications.apiTokenActivateHint")}
|
||||
</p>
|
||||
)}
|
||||
{token && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.apiTokenOnceHint")}
|
||||
</p>
|
||||
)}
|
||||
{copied === "token" && (
|
||||
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* request examples */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs font-medium">
|
||||
{t("medications.requestExample")}
|
||||
</Label>
|
||||
<Select
|
||||
value={exampleType}
|
||||
onValueChange={(value) => setExampleType(value as ExampleType)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-[170px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="curl">cURL</SelectItem>
|
||||
<SelectItem value="wget">wget</SelectItem>
|
||||
<SelectItem value="fetch">JavaScript fetch</SelectItem>
|
||||
<SelectItem value="powershell">PowerShell</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<pre className="bg-muted rounded-lg p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{selectedExample.value}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-7 w-7"
|
||||
onClick={() => copyText(selectedExample.value, "example")}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{copied === "example" && (
|
||||
<p className="text-dracula-green text-xs">{t("common.copied")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{displayMsg && (
|
||||
<p
|
||||
className={`text-sm ${displayMsg === t("medications.apiEndpointActivated") || displayMsg === t("medications.apiEndpointDeactivated") ? "text-dracula-green" : "text-destructive"}`}
|
||||
>
|
||||
{displayMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={loadingStatus || toggling}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void refetchStatus();
|
||||
}}
|
||||
disabled={loadingStatus || toggling}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t("medications.retryLoad")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleClose}>{t("common.close")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.5.5 D-3 §10 invariant 19 — `IntakeImportDialog` lifted out of
|
||||
* `src/app/medications/page.tsx` so the detail-page intake-history
|
||||
* header can mount it as the only CSV-import affordance. The list
|
||||
* page previously owned the only mount + the per-card kebab triggered
|
||||
* `setImportMedId(id)`. v1.5.5 retires the per-card trigger; the
|
||||
* detail page is the only surface that opens the dialog, and the
|
||||
* intake-history preview owns the trigger so the import lives next to
|
||||
* the table it changes.
|
||||
*
|
||||
* The contract is preserved verbatim from the v1.5.4 list-page copy
|
||||
* so the integration test surface stays familiar: open via
|
||||
* `medicationId` going non-null, close via `onClose`. The dialog
|
||||
* resets every transient piece of state on close so the next open
|
||||
* never picks up the previous session's text.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, MoreHorizontal, RotateCcw, Upload } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
export interface IntakeImportDialogProps {
|
||||
medicationId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function IntakeImportDialog({
|
||||
medicationId,
|
||||
onClose,
|
||||
}: IntakeImportDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
const [resultType, setResultType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedFileName, setSelectedFileName] = useState<string | null>(null);
|
||||
const [fileInputKey, setFileInputKey] = useState(0);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
function resetImportForm() {
|
||||
setJsonText("");
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
setSelectedFileName(null);
|
||||
setFileInputKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetImportForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
JSON.parse(content);
|
||||
setJsonText(content);
|
||||
setSelectedFileName(file.name);
|
||||
setResult(t("medications.importFileLoaded", { name: file.name }));
|
||||
setResultType("success");
|
||||
} catch {
|
||||
setResult(t("medications.importInvalidJson"));
|
||||
setResultType("error");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (!medicationId || !jsonText.trim()) return;
|
||||
setImporting(true);
|
||||
setResult(null);
|
||||
setResultType(null);
|
||||
|
||||
try {
|
||||
let data = JSON.parse(jsonText.trim());
|
||||
// Support both array and object-with-array
|
||||
if (!Array.isArray(data)) {
|
||||
const arrKey = Object.keys(data).find((k) => Array.isArray(data[k]));
|
||||
if (arrKey) data = data[arrKey];
|
||||
else throw new Error(t("medications.importNoArray"));
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/intake/import`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
if (res.ok) {
|
||||
const d = json.data;
|
||||
setResult(
|
||||
t("medications.importResult", { imported: d.imported }) +
|
||||
(d.skippedDuplicates > 0
|
||||
? `, ${t("medications.importDuplicatesSkipped", { count: d.skippedDuplicates })}`
|
||||
: "") +
|
||||
(d.skippedInvalid > 0
|
||||
? `, ${t("medications.importInvalidSkipped", { count: d.skippedInvalid })}`
|
||||
: ""),
|
||||
);
|
||||
setResultType("success");
|
||||
void invalidateKeys(queryClient, medicationDependentKeys);
|
||||
} else {
|
||||
setResult(json.error || t("medications.importFailed"));
|
||||
setResultType("error");
|
||||
}
|
||||
} catch (err) {
|
||||
setResult(
|
||||
err instanceof SyntaxError
|
||||
? t("medications.importInvalidFormat")
|
||||
: (err as Error).message || t("medications.importFailed"),
|
||||
);
|
||||
setResultType("error");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!medicationId} onOpenChange={() => handleClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("medications.importIntakes")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t("medications.importDescription")}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="intake-import-file" className="text-xs font-medium">
|
||||
{t("medications.importUploadFile")}
|
||||
</Label>
|
||||
<input
|
||||
key={fileInputKey}
|
||||
id="intake-import-file"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="border-input bg-background text-foreground file:bg-muted file:text-foreground w-full cursor-pointer rounded-md border text-sm file:mr-2 file:border-0 file:px-3 file:py-2"
|
||||
/>
|
||||
{selectedFileName && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.importSelected", { name: selectedFileName })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-muted text-muted-foreground rounded-lg p-3 text-xs">
|
||||
{`[
|
||||
{"datum": "2026-02-14", "uhrzeit": "10:27:43", "zaehler": 523},
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
className={`text-sm ${resultType === "success" ? "text-dracula-green" : "text-destructive"}`}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{result}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
disabled={importing}
|
||||
aria-label={t("common.moreOptions")}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={resetImportForm}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{t("common.reset")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={importing}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
disabled={importing || !jsonText.trim()}
|
||||
aria-busy={importing || undefined}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
{importing ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("common.import")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -223,6 +223,16 @@ describe("dependent-key bundles", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
|
||||
});
|
||||
|
||||
// v1.5.5 D-3 §10 invariant 20 — the per-medication inline compliance
|
||||
// chart cache key (`["compliance-chart-inline", medicationId]`) needs
|
||||
// its prefix in the bundle so every detail-page mutation evicts the
|
||||
// tile in one tick. Hierarchical prefix-match catches every per-id
|
||||
// slot beneath it.
|
||||
it("medicationDependentKeys bundles the compliance-chart-inline prefix (v1.5.5)", () => {
|
||||
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
|
||||
expect(keyStrings).toContain(JSON.stringify(["compliance-chart-inline"]));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -353,6 +353,19 @@ export const moodDependentKeys = [
|
||||
* `["dashboard-medication-compliance"]` lands in the bundle so an
|
||||
* intake POST refreshes the chart immediately rather than waiting for
|
||||
* `staleTime` (audit L4).
|
||||
*
|
||||
* v1.5.5 D-3 §10 invariant 20 (was C-E2-1 / H-cluster-G) — the
|
||||
* per-medication inline compliance chart used to mount under
|
||||
* `queryKeys.medicationComplianceChart(medicationId)` which expands to
|
||||
* `["compliance-chart-inline", id]`. The prefix `["compliance-chart-inline"]`
|
||||
* lands in the bundle so every detail-page mutation (today's-dose,
|
||||
* Pausieren, end, purge, edit) evicts the inline compliance tile in
|
||||
* one tick. The TanStack hierarchical-prefix semantics catch every
|
||||
* per-medication slot under that prefix.
|
||||
*
|
||||
* `queryKeys.medicationDetail(id)` rides under the
|
||||
* `["medications"]` prefix already so a single medication invalidation
|
||||
* also evicts its detail-page read.
|
||||
*/
|
||||
export const medicationDependentKeys = [
|
||||
queryKeys.medications(),
|
||||
@@ -361,6 +374,7 @@ export const medicationDependentKeys = [
|
||||
queryKeys.insightsTargets(),
|
||||
queryKeys.gamificationAchievements(),
|
||||
["dashboard-medication-compliance"] as const,
|
||||
["compliance-chart-inline"] as const,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user