Files
HealthLog/src/components/admin/ai-usage-section.tsx
T
Marc-André Bombeck 69e2f18788 fix(ui): tidy the new record and settings surfaces at phone width
- Composition amounts show only the decimals they carry, so 100 µg no
  longer reads as 100.000 µg; the remove button stays on the amount row.
- AUDIT-C remove controls are icon-only, so they no longer spill out of
  the card or cover the earlier answer.
- Life-circumstance labels keep their colon when they wrap, checkbox
  groups sit in two columns and the weekly-minutes sentence ends.
- Link chips get a 44 px hit area for their remove control.
- The AI usage level reports a failed save with a sentence and the
  error id instead of a bare "Error".
- The Dawarich home-country and admin refresh-gap labels sit above
  their fields.
2026-10-16 08:03:29 +02:00

232 lines
7.5 KiB
TypeScript

"use client";
/**
* v1.43 (#1217) — the operator's AI usage bounds.
*
* The operator pays for the shared key, so the operator sets the highest
* level an account may choose and the level an account without a choice runs
* at; each person picks within that under Settings > AI. Beside them, the
* gap between two status-card regenerations after a sync at `standard`
* (whole hours, 1 to 48; empty follows `AI_SYNC_REGEN_GAP_HOURS`, else six).
*
* Reads and writes the `aiUsage` block of the assistant-flags route, the same
* query the assistant switches use. Lowering the ceiling caps every account
* above it at once; their stored choice is kept.
*/
import { useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Gauge } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
AI_USAGE_LEVELS,
SYNC_REGEN_GAP_HOURS_MAX,
SYNC_REGEN_GAP_HOURS_MIN,
usageLevelRank,
type AiUsageLevel,
} from "@/lib/ai/usage-level";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import {
aiInputDependentKeys,
invalidateKeys,
queryKeys,
} from "@/lib/query-keys";
interface AiUsageBounds {
maxLevel: AiUsageLevel;
defaultLevel: AiUsageLevel;
syncRegenGapHours: number | null;
effectiveSyncRegenGapHours: number;
}
const LEVEL_LABEL_KEYS = {
frugal: "settings.ai.usage.levels.frugal.label",
standard: "settings.ai.usage.levels.standard.label",
full: "settings.ai.usage.levels.full.label",
} as const satisfies Record<AiUsageLevel, string>;
export function AiUsageSection() {
const { t } = useTranslations();
const { data } = useQuery({
queryKey: queryKeys.adminAssistantFlags(),
queryFn: () =>
apiGet<{ aiUsage?: AiUsageBounds }>(
"/api/admin/settings/assistant-flags",
),
});
const bounds = data?.aiUsage;
if (!bounds) {
return (
<SettingsCard>
<SettingsCardHeader
icon={Gauge}
title={t("admin.aiUsage.title")}
description={t("admin.aiUsage.description")}
/>
</SettingsCard>
);
}
// Keyed on the stored values, so a save (or another operator's) re-seeds
// the form instead of leaving it on stale edits.
return (
<AiUsageForm
key={`${bounds.maxLevel}:${bounds.defaultLevel}:${bounds.syncRegenGapHours}`}
bounds={bounds}
/>
);
}
function AiUsageForm({ bounds }: { bounds: AiUsageBounds }) {
const { t } = useTranslations();
const client = useQueryClient();
const [maxLevel, setMaxLevel] = useState<AiUsageLevel>(bounds.maxLevel);
const [defaultLevel, setDefaultLevel] = useState<AiUsageLevel>(
bounds.defaultLevel,
);
const [gap, setGap] = useState(
bounds.syncRegenGapHours === null ? "" : String(bounds.syncRegenGapHours),
);
const gapValue = gap.trim() === "" ? null : Number(gap);
const gapValid =
gapValue === null ||
(Number.isInteger(gapValue) &&
gapValue >= SYNC_REGEN_GAP_HOURS_MIN &&
gapValue <= SYNC_REGEN_GAP_HOURS_MAX);
const defaultAbove = usageLevelRank(defaultLevel) > usageLevelRank(maxLevel);
const save = useMutation({
mutationFn: () =>
apiPut<{ aiUsage: AiUsageBounds }>(
"/api/admin/settings/assistant-flags",
{
aiUsage: { maxLevel, defaultLevel, syncRegenGapHours: gapValue },
},
),
onSuccess: (result) => {
client.setQueryData(queryKeys.adminAssistantFlags(), result);
void client.invalidateQueries({
queryKey: queryKeys.adminProviderHealth(),
});
void invalidateKeys(client, aiInputDependentKeys);
toast.success(t("common.saved"));
},
onError: (err) => {
showErrorToast(
err instanceof Error && err.message
? err.message
: t("admin.settingsSaveError"),
{ cause: err },
);
},
});
function onSubmit(event: FormEvent) {
event.preventDefault();
if (!gapValid || defaultAbove) return;
save.mutate();
}
return (
<SettingsCard data-slot="admin-ai-usage">
<SettingsCardHeader
icon={Gauge}
title={t("admin.aiUsage.title")}
description={t("admin.aiUsage.description")}
/>
<form onSubmit={onSubmit} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-max">
{t("admin.aiUsage.maxLabel")}
</Label>
<NativeSelect
id="admin-ai-usage-max"
value={maxLevel}
disabled={save.isPending}
onChange={(e) => setMaxLevel(e.target.value as AiUsageLevel)}
>
{AI_USAGE_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_LABEL_KEYS[level])}
</option>
))}
</NativeSelect>
</div>
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-default">
{t("admin.aiUsage.defaultLabel")}
</Label>
<NativeSelect
id="admin-ai-usage-default"
value={defaultLevel}
disabled={save.isPending}
aria-invalid={defaultAbove || undefined}
onChange={(e) => setDefaultLevel(e.target.value as AiUsageLevel)}
>
{AI_USAGE_LEVELS.map((level) => (
<option key={level} value={level}>
{t(LEVEL_LABEL_KEYS[level])}
</option>
))}
</NativeSelect>
</div>
</div>
{defaultAbove ? (
<p role="alert" className="text-destructive text-xs">
{t("admin.aiUsage.defaultAboveCeiling")}
</p>
) : null}
<div className="space-y-1.5">
<Label htmlFor="admin-ai-usage-gap" className="block leading-snug">
{t("admin.aiUsage.gapLabel")}
</Label>
<Input
id="admin-ai-usage-gap"
type="number"
inputMode="numeric"
min={SYNC_REGEN_GAP_HOURS_MIN}
max={SYNC_REGEN_GAP_HOURS_MAX}
step={1}
className="sm:max-w-40"
value={gap}
placeholder={String(bounds.effectiveSyncRegenGapHours)}
disabled={save.isPending}
aria-invalid={!gapValid || undefined}
aria-describedby="admin-ai-usage-gap-hint"
onChange={(e) => setGap(e.target.value)}
/>
<p
id="admin-ai-usage-gap-hint"
className="text-muted-foreground text-xs"
>
{t("admin.aiUsage.gapHint", {
hours: bounds.effectiveSyncRegenGapHours,
})}
</p>
</div>
<SettingsCardActions>
<Button
type="submit"
size="sm"
className="min-h-11 sm:min-h-9"
disabled={save.isPending || !gapValid || defaultAbove}
>
{t("common.save")}
</Button>
</SettingsCardActions>
</form>
</SettingsCard>
);
}