Compare commits

...

7 Commits

Author SHA1 Message Date
Khalid Abdi 62a5f39683 chore: release v0.14.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:22 +03:00
Khalid Abdi 6127a0ac42 frontend: build with webpack so Docker images build on arm64
Next 16's default Turbopack build has no native bindings for linux/arm64 in
the Alpine image, so `next build` failed in Docker ("Turbopack is not
supported on this platform"). Switch the production build to webpack, which
Next recommends for this case and which builds on every arch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:35:02 +03:00
Khalid Abdi 352ca65838 chore: release v0.14.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:31:54 +03:00
Khalid Abdi 44d653ffbd frontend: compose settings frames from CardFrame primitives
SettingsFrame used a hand-rolled <div className="p-5"> body and
SettingsCard was a plain div (no data-slot=card), so the frame's card
styling never applied.

- Add CardFramePanel (data-slot="card-frame-panel") as the padded frame
  body, mirroring the other CardFrame* subcomponents.
- SettingsFrame now composes CardFrameHeader + CardFramePanel (same p-5
  padding as before) instead of a raw div.
- SettingsCard renders a real COSS Card (data-slot=card). Since Card is
  flex flex-col, the horizontal-row call sites (ToggleRow, billing and
  preferences rows) now pass flex-row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:19 +03:00
Khalid Abdi 76da310766 frontend: fix wallet push silently skipping in form dialogs
The "send to wallet" step in the appointment/invoice/prescription/
patient-edit/scribe dialogs was gated on sync.linked, which resolves
asynchronously via getWalletLink in an effect. A fast save (or a
transient failure that collapsed it to false) left a wallet-linked
patient looking unlinked, so the dialog took the else branch and pushed
nothing.

- Add ensureLinked() to useWalletSync: awaits getWalletLink at submit and
  returns the resolved status. Each of the 5 dialogs now awaits it before
  deciding whether to show the wallet step.
- Harden push() to drop empty/whitespace changes (the backend 400s on an
  empty change set) and add a translated summaryFallback so the step
  never sends an empty summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:10:04 +03:00
Khalid Abdi c326e9f794 chore: release v0.14.0
Bump root/backend/frontend to 0.14.0, move Unreleased notes under a dated
0.14.0 heading, and fix the stale README changelog badge (0.2.1 → 0.14.0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:48 +03:00
Khalid Abdi b299501ab2 frontend+backend: patients/settings UI, AI Auto/Off modes, wallet doc push
- patients: move status filter to its own "Filter" row above the table
- patient sheet: name + ⋯ menu on one left row; Edit moved into the ⋯ menu
- settings: redesign sections with the COSS CardFrame surface (SettingsFrame)
- AI: add Automatic (auto-pick provider) and Off modes; default to Automatic
  so a fresh install shows the setup banner until a provider is configured
- AI: fix the setup banner never showing (defaulted Ollama URL counted as
  configured); add an "AI off" notice; add a fallback render for unknown chat
  data parts so cards never silently vanish
- backend: include patient attachment metadata in the wallet record-update
  bundle so pushed documents reach the wallet
- i18n: add mode/off/card keys across all five locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:22:36 +03:00
33 changed files with 569 additions and 234 deletions
+60
View File
@@ -7,6 +7,66 @@ for how releases are cut and published.
## [Unreleased]
## [0.14.2] — 2026-07-15
### Fixed
- **Docker images build on ARM64 again.** Next 16's default Turbopack production build has no native
bindings for `linux/arm64` in the Alpine image, so `docker compose up --build` failed with
"Turbopack is not supported on this platform". The frontend now builds with Webpack
(`next build --webpack`), which builds on every architecture (`frontend/package.json`).
## [0.14.1] — 2026-07-15
### Fixed
- **"Send to wallet" now works from every dialog, not just the patient sheet.** The wallet step in
the appointment, invoice, prescription, patient-edit, and scribe dialogs was gated on a wallet-link
check that resolved asynchronously; if the clinician saved before it resolved (or it briefly
failed), the dialog silently closed without pushing. The dialogs now await the link check before
deciding, and an empty change summary can no longer be sent
(`frontend/components/wallet/use-wallet-sync.ts`, `wallet-sync-step.tsx`).
### Changed
- **Settings sections composed from `CardFrame` primitives.** Each settings section now builds on
`CardFrameHeader`/`CardFrameTitle`/`CardFrameDescription` + a new `CardFramePanel` body, and the
per-row `SettingsCard` renders a real `Card`, giving a consistent framed surface
(`frontend/components/ui/card.tsx`, `frontend/components/settings/settings-parts.tsx`).
## [0.14.0] — 2026-07-13
### Changed
- **Patients page filter moved to its own row.** The status filter left the toolbar and now sits on a
dedicated **"Filter"** row directly above the table, so the header stays a clean title + search +
"Add patient" + `⋯` cluster (`frontend/components/patients/patients-view.tsx`).
- **Patient detail sheet header.** The patient name, status badge, and the `⋯` menu now share one
left-aligned row, and the standalone **Edit** button moved to be the first item inside the `⋯` menu
(`frontend/components/patients/patient-detail.tsx`).
- **Settings redesigned with the COSS frame surface.** Every settings section now renders in a
`CardFrame` (header + body) via a new `SettingsFrame` part, replacing the flat `SettingsCard` divs
(`frontend/components/settings/settings-parts.tsx`, `settings-ai.tsx`).
### Added
- **AI Mode: Automatic and Off.** Settings → AI gains two modes beyond API / Local: **Automatic**
(use a cloud API key when set, else fall back to local Ollama) and **Off** (assistant disabled).
Automatic is the new default, so a fresh install shows the setup banner until a provider is wired
(`frontend/lib/ai-settings.ts`, `backend/src/services/ai/{config,provider}.ts`, `types/ai.ts`).
### Fixed
- **AI setup banner now appears when no provider is configured.** Previously the defaulted Ollama URL
counted as "configured," so the "connect an AI model" banner never showed; it now reflects the
actual mode (`frontend/components/chat/ai-setup-notice.tsx`).
- **Chat cards no longer silently vanish.** An unrecognized/renamed streamed data part now renders a
small placeholder instead of nothing (`frontend/components/chat/chat-panel.tsx`).
### Wallet app
- **Documents/files reach the wallet.** Clinic record-update pushes now include attachment metadata,
which the wallet folds into the record so the **Documents** tile counts them and the Documents
screen lists them (`backend/src/services/wallet-updates.ts`, `temetro-app` types + home/documents).
- **Failed pushes are no longer swallowed.** A record update that fails signature verification or
decoding is now logged (and a "couldn't verify" notice is raised) instead of disappearing silently.
- **i18next translation.** The wallet app now uses `i18next` + `react-i18next` + `expo-localization`
(English shipped; other locales can be added later), with the home, record-update inbox, navigation
titles, and the visits/prescriptions-adjacent detail screens extracted to translation keys.
## [0.13.1] — 2026-07-13
### Changed
+1 -1
View File
@@ -9,7 +9,7 @@ information as rich record cards — backed by a **patient-owned data model**.
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
[![Docker images](https://img.shields.io/badge/Docker%20Hub-khalidxv-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/u/khalidxv)
[![Changelog](https://img.shields.io/badge/changelog-0.2.1-success)](./CHANGELOG.md)
[![Changelog](https://img.shields.io/badge/changelog-0.14.0-success)](./CHANGELOG.md)
![temetro AI chat](./.github/assets/screenshot-chat.png)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.13.1",
"version": "0.14.2",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+1 -1
View File
@@ -5,7 +5,7 @@ import { z } from "zod";
// is the plaintext key for the *currently selected* provider — it is encrypted
// before storage and never echoed back.
export const aiConfigInputSchema = z.object({
mode: z.enum(["api", "local"]).optional(),
mode: z.enum(["api", "local", "auto", "off"]).optional(),
provider: z.enum(["openai", "anthropic", "gemini"]).optional(),
ollamaBaseUrl: z.string().url().optional(),
ollamaModel: z.string().min(1).max(120).optional(),
+6
View File
@@ -208,6 +208,12 @@ chatRouter.post("/", async (req, res, next) => {
}
const settings = await getAiSettings(req.user!.id);
if (settings.mode === "off") {
throw new HttpError(
400,
"The AI assistant is turned off. Turn it on in Settings → AI.",
);
}
const modelId = requestedModel || settings.defaultModel;
const resolved = resolveModel(settings, modelId);
const veil = createVeil(settings.veilLevel, resolved.isExternal);
+3 -1
View File
@@ -13,7 +13,9 @@ import {
type AiSettingsRow = typeof userAiSettings.$inferSelect;
const DEFAULTS: Omit<AiSettingsRow, "userId" | "updatedAt"> = {
mode: "local",
// Default to auto: use a cloud key if the user adds one, else local Ollama.
// A fresh user with nothing configured then sees the setup banner.
mode: "auto",
provider: "anthropic",
ollamaBaseUrl: DEFAULT_OLLAMA_BASE_URL,
ollamaModel: "llama3.1",
+25 -7
View File
@@ -69,8 +69,30 @@ export function resolveModel(
settings: AiSettingsRow,
requestedModelId: string,
): ResolvedModel {
// Local mode (or the local sentinel) → Ollama's OpenAI-compatible endpoint.
if (settings.mode === "local" || requestedModelId === OLLAMA_SENTINEL) {
// Off → the assistant is disabled. Guard here in case a request slips past the
// route-level short-circuit.
if (settings.mode === "off") {
throw new HttpError(
400,
"The AI assistant is turned off. Turn it on in Settings → AI.",
);
}
const requested = providerForModel(requestedModelId);
// In api/auto mode, find a cloud provider that actually has a key. Auto with
// no key (and api mode's remaining fall-through) drops to local Ollama below.
const provider =
settings.mode === "api" || settings.mode === "auto"
? chooseProvider(settings, requested)
: null;
// Local when: explicit local mode, the local sentinel was picked, or auto with
// no configured cloud key. → Ollama's OpenAI-compatible endpoint.
if (
settings.mode === "local" ||
requestedModelId === OLLAMA_SENTINEL ||
(settings.mode === "auto" && !provider)
) {
const ollama = createOpenAICompatible({
name: "ollama",
baseURL: `${settings.ollamaBaseUrl.replace(/\/$/, "")}/v1`,
@@ -82,11 +104,7 @@ export function resolveModel(
};
}
// API mode. Pick a provider that actually has a key, falling back gracefully
// so a configured key (e.g. Gemini) is used even if the picked model belongs
// to a different, unconfigured provider.
const requested = providerForModel(requestedModelId);
const provider = chooseProvider(settings, requested);
// API mode with a picked cloud model but no matching/any key.
if (!provider) {
throw new HttpError(
400,
+14 -3
View File
@@ -15,6 +15,7 @@ import {
} from "../lib/wallet-crypto.js";
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
import { listAppointments } from "./appointments.js";
import { listAttachments } from "./attachments.js";
import { listInvoices } from "./invoices.js";
import { getPatient } from "./patients.js";
import { signWithClinicKey } from "./signing.js";
@@ -122,19 +123,29 @@ export async function createRecordUpdate(
// Appointments and invoices live in their own tables (not on the Patient
// snapshot), so pull the ones for this patient and ship them alongside — the
// wallet has no other way to see them and they'd otherwise silently vanish.
const [orgAppointments, orgInvoices] = await Promise.all([
// Attachments (files/documents) are shipped as metadata so the wallet can list
// them and show a count; the bytes stay on the clinic for now.
const [orgAppointments, orgInvoices, attachmentRows] = await Promise.all([
listAppointments(orgId),
listInvoices(orgId),
listAttachments(orgId, fileNumber),
]);
const appointments = orgAppointments.filter(
(a) => a.fileNumber === fileNumber,
);
const invoices = orgInvoices.filter((i) => i.fileNumber === fileNumber);
const documents = attachmentRows.map((a) => ({
id: a.id,
filename: a.filename,
mimeType: a.mimeType,
sizeBytes: a.sizeBytes,
createdAt: a.createdAt,
}));
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient` (+ appointments/invoices).
// replaces its on-device record with `patient` (+ appointments/invoices/documents).
const bundle = utf8ToBytes(
JSON.stringify({ patient, appointments, invoices, changes }),
JSON.stringify({ patient, appointments, invoices, documents, changes }),
);
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
+6 -2
View File
@@ -2,8 +2,12 @@
// AI panel. The chat agent reads these to decide which provider/model to call
// and how strict the Veil de-identification safeguard should be.
// Two inference modes: a user-provided cloud API key, or a local Ollama model.
export type AiMode = "api" | "local";
// Inference modes:
// api — a user-provided cloud API key
// local — a local Ollama model
// auto — auto-pick: use a cloud key when one is set, else fall back to local
// off — the assistant is disabled
export type AiMode = "api" | "local" | "auto" | "off";
// The three supported cloud providers for API-key mode.
export type ApiProvider = "openai" | "anthropic" | "gemini";
@@ -195,7 +195,7 @@ export function AddAppointmentDialog({
t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.appointment", { date: keyOf(date), time }),
);
+29 -9
View File
@@ -14,9 +14,13 @@ import { getAiConfig } from "@/lib/ai-settings";
// message just fails silently, so the banner spells out the missing setup and
// links straight to AI settings. Rendered in both the empty and active chat
// states so a user mid-conversation with no provider still sees why replies fail.
// Which advisory (if any) to show: the setup nudge (no provider wired), or a
// notice that the assistant has been switched off.
type Notice = "setup" | "off" | null;
export function AiSetupNotice() {
const { t } = useTranslation();
const [needsSetup, setNeedsSetup] = useState(false);
const [notice, setNotice] = useState<Notice>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
@@ -24,22 +28,34 @@ export function AiSetupNotice() {
getAiConfig()
.then((cfg) => {
if (!active) return;
// Configured = an API key for any provider, or a local Ollama endpoint.
if (cfg.mode === "off") {
setNotice("off");
return;
}
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
const hasLocal =
cfg.mode === "local" && cfg.ollamaBaseUrl.trim().length > 0;
setNeedsSetup(!(hasApiKey || hasLocal));
// Whether the assistant is actually wired up for the chosen mode:
// - api / auto → needs a cloud API key (auto silently falls back to
// local, but with no key we nudge the user to finish
// setup rather than depend on an unverified Ollama).
// - local → needs a non-empty Ollama endpoint (opted in).
const configured =
cfg.mode === "local"
? cfg.ollamaBaseUrl.trim().length > 0
: hasApiKey;
setNotice(configured ? null : "setup");
})
.catch(() => {
// If we can't read the config, don't nag — the chat still works.
if (active) setNeedsSetup(false);
if (active) setNotice(null);
});
return () => {
active = false;
};
}, []);
if (!needsSetup || dismissed) return null;
if (!notice || dismissed) return null;
const isOff = notice === "off";
return (
<div
@@ -48,10 +64,14 @@ export function AiSetupNotice() {
>
<TriangleAlert className="size-4 shrink-0 text-warning" />
<p className="min-w-0 flex-1 truncate text-foreground">
<span className="font-medium">{t("chat.setupNotice.title")}</span>
<span className="font-medium">
{isOff
? t("chat.setupNotice.offTitle")
: t("chat.setupNotice.title")}
</span>
<span className="text-muted-foreground max-sm:hidden">
{" — "}
{t("chat.setupNotice.body")}
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
</span>
</p>
<Button
+27 -1
View File
@@ -188,7 +188,17 @@ export function ChatPanel() {
getAiConfig()
.then((cfg) => {
if (cancelled) return;
setModel(cfg.mode === "local" ? "ollama" : cfg.defaultModel);
// Seed the model to match the configured mode. In "auto" we prefer the
// user's cloud default when a key exists, else fall back to the local
// sentinel (mirrors the backend's provider resolution).
const hasApiKey = Object.values(cfg.apiKeySet).some(Boolean);
const seeded =
cfg.mode === "local"
? "ollama"
: cfg.mode === "auto" && !hasApiKey
? "ollama"
: cfg.defaultModel;
setModel(seeded);
setEffort(cfg.defaultEffort);
})
.catch(() => {
@@ -687,6 +697,22 @@ export function ChatPanel() {
</Badge>
);
}
// Fallback: a structured card the client doesn't recognize (e.g. a
// data part added or renamed on the backend). Surface a small
// placeholder so a written-but-unhandled result is never silently
// invisible. `data-step`/`data-source` intentionally render nothing
// here (they're consumed above), so skip them.
if (
part.type.startsWith("data-") &&
part.type !== "data-step" &&
part.type !== "data-source"
) {
return (
<Badge className="self-start" key={key} variant="outline">
{t("chat.card.unsupported")}
</Badge>
);
}
return null;
})}
@@ -396,7 +396,7 @@ export function PatientFormDialog({
t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }),
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setStep("wallet");
return;
}
@@ -273,7 +273,7 @@ export function InvoiceFormDialog({
})
: await createInvoice(payload);
onSaved(saved);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
mode === "edit"
? t("walletSync.summary.invoiceUpdated", { number: saved.number })
+55 -58
View File
@@ -303,6 +303,8 @@ export function PatientDetail({
<AvatarFallback>{patient.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{/* Name, status, and the overflow menu share one left-aligned row;
Edit now lives inside the menu rather than as a separate button. */}
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-base text-foreground">
{patient.name}
@@ -310,6 +312,59 @@ export function PatientDetail({
<Badge variant={statusVariant[patient.status]}>
{t(`patients.status.${patient.status}`)}
</Badge>
<Menu>
<MenuTrigger
render={
<Button
aria-label={t("patientCard.moreActions")}
size="icon-sm"
type="button"
variant="outline"
/>
}
>
<MoreHorizontal className="size-4" />
</MenuTrigger>
<MenuPopup align="start">
{onEdit && (
<MenuItem onClick={onEdit}>
<Pencil className="size-4" />
{t("patientCard.edit")}
</MenuItem>
)}
<MenuItem onClick={() => printPatientSummary(patient, t)}>
<FileDown className="size-4" />
{t("patientCard.exportPdf")}
</MenuItem>
{onScribe && (
<MenuItem onClick={onScribe}>
<Mic className="size-4" />
{t("scribe.recordVisit")}
</MenuItem>
)}
{onTransfer && (
<MenuItem onClick={onTransfer}>
<ArrowLeftRight className="size-4" />
{t("patients.transfer.action")}
</MenuItem>
)}
{onWalletPush && (
<MenuItem onClick={onWalletPush}>
<Send className="size-4" />
{t("walletPush.action")}
</MenuItem>
)}
{onDelete && (
<>
<MenuSeparator />
<MenuItem onClick={onDelete} variant="destructive">
<Trash2 className="size-4" />
{t("patients.delete.action")}
</MenuItem>
</>
)}
</MenuPopup>
</Menu>
</div>
<span className="text-muted-foreground text-sm">{idLine}</span>
{patient.alerts.length > 0 && (
@@ -323,64 +378,6 @@ export function PatientDetail({
)}
</div>
</div>
{/* Actions — one primary (Edit) with the rest tucked into an overflow
menu so the header stays uncluttered. */}
<div className="flex items-center gap-2">
{onEdit && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
<Pencil className="size-4" />
{t("patientCard.edit")}
</Button>
)}
<Menu>
<MenuTrigger
render={
<Button
aria-label={t("patientCard.moreActions")}
className="ms-auto"
size="icon-sm"
type="button"
variant="outline"
/>
}
>
<MoreHorizontal className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem onClick={() => printPatientSummary(patient, t)}>
<FileDown className="size-4" />
{t("patientCard.exportPdf")}
</MenuItem>
{onScribe && (
<MenuItem onClick={onScribe}>
<Mic className="size-4" />
{t("scribe.recordVisit")}
</MenuItem>
)}
{onTransfer && (
<MenuItem onClick={onTransfer}>
<ArrowLeftRight className="size-4" />
{t("patients.transfer.action")}
</MenuItem>
)}
{onWalletPush && (
<MenuItem onClick={onWalletPush}>
<Send className="size-4" />
{t("walletPush.action")}
</MenuItem>
)}
{onDelete && (
<>
<MenuSeparator />
<MenuItem onClick={onDelete} variant="destructive">
<Trash2 className="size-4" />
{t("patients.delete.action")}
</MenuItem>
</>
)}
</MenuPopup>
</Menu>
</div>
</div>
<Section title={t("patientCard.overview")}>
+32 -24
View File
@@ -173,29 +173,6 @@ export function PatientsView() {
value={query}
/>
</div>
<Select
onValueChange={(value) => {
setStatusFilter((value ?? "all") as StatusFilter);
setPage(1);
}}
value={statusFilter}
>
<SelectTrigger
aria-label={t("patients.filterStatus")}
className="w-full sm:w-40"
>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{STATUS_FILTERS.map((value) => (
<SelectItem key={value} value={value}>
{value === "all"
? t("patients.allStatuses")
: t(`patients.status.${value}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
<Button
className="rounded-3xl"
onClick={() => {
@@ -233,7 +210,38 @@ export function PatientsView() {
</div>
</div>
<CardFrame className="mt-8 w-full">
{/* Filter sits on its own row just above the table so the toolbar stays a
clean title + search + primary actions cluster. */}
<div className="mt-8 flex items-center gap-3">
<span className="text-sm font-medium text-muted-foreground">
{t("patients.filterLabel")}
</span>
<Select
onValueChange={(value) => {
setStatusFilter((value ?? "all") as StatusFilter);
setPage(1);
}}
value={statusFilter}
>
<SelectTrigger
aria-label={t("patients.filterStatus")}
className="w-40"
>
<SelectValue />
</SelectTrigger>
<SelectPopup>
{STATUS_FILTERS.map((value) => (
<SelectItem key={value} value={value}>
{value === "all"
? t("patients.allStatuses")
: t(`patients.status.${value}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<CardFrame className="mt-4 w-full">
<Table variant="card">
<TableHeader>
<TableRow>
@@ -233,7 +233,7 @@ export function ScribeDialog({
const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated);
if (sync.linked) {
if (await sync.ensureLinked()) {
setPhase("review");
setWalletStep(true);
} else {
@@ -347,7 +347,7 @@ export function AddPrescriptionDialog({
t("prescriptions.dialog.addedTitle"),
`${medication.trim()} · ${selected.name}`,
);
if (sync.linked) {
if (await sync.ensureLinked()) {
setWalletSummary(
t("walletSync.summary.prescription", { drug: medication.trim() }),
);
+100 -82
View File
@@ -15,8 +15,7 @@ import {
} from "@/components/ui/select";
import {
FieldLabel,
SettingsCard,
SettingsSection,
SettingsFrame,
ToggleRow,
} from "@/components/settings/settings-parts";
import {
@@ -41,7 +40,7 @@ const PROVIDERS: ApiProvider[] = ["openai", "anthropic", "gemini"];
const VEIL_LEVELS: VeilLevel[] = ["full", "names", "off"];
const DEFAULTS: AiConfig = {
mode: "local",
mode: "auto",
provider: "anthropic",
ollamaBaseUrl: "http://localhost:11434",
ollamaModel: "llama3.1",
@@ -196,15 +195,26 @@ export function AIPanel() {
const keyIsSet = config.apiKeySet[config.provider];
// Which mode-hint copy to show under the Mode select.
const modeHintSuffix =
config.mode === "api"
? "Api"
: config.mode === "local"
? "Local"
: config.mode === "off"
? "Off"
: "Auto";
return (
<>
{policy ? (
<SettingsSection
<SettingsFrame
bodyClassName="space-y-3"
description={t("settings.ai.availability.description")}
title={t("settings.ai.availability.title")}
>
{isAdmin ? (
<div className="space-y-3">
<>
<ToggleRow
checked={policy.aiEnabled}
description={t("settings.ai.availability.enabledHint")}
@@ -240,58 +250,67 @@ export function AIPanel() {
</Button>
</div>
) : null}
</div>
</>
) : (
<SettingsCard className="px-4 py-3.5">
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
? t("settings.ai.availability.readonlyEmployeesOnly")
: t("settings.ai.availability.readonlyEnabled")
: t("settings.ai.availability.readonlyDisabled")}
</p>
</SettingsCard>
<p className="text-sm text-muted-foreground">
{policy.aiEnabled
? policy.disabledForEmployees
? t("settings.ai.availability.readonlyEmployeesOnly")
: t("settings.ai.availability.readonlyEnabled")
: t("settings.ai.availability.readonlyDisabled")}
</p>
)}
</SettingsSection>
</SettingsFrame>
) : null}
<SettingsSection
<SettingsFrame
description={t("settings.ai.modeDescription")}
title={t("settings.ai.modeTitle")}
>
<SettingsCard className="space-y-5 p-5">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
<Select
onValueChange={(value) => set("mode", value as AiMode)}
value={config.mode}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
<SelectItem value="local">
{t("settings.ai.modeLocal")}
</SelectItem>
</SelectPopup>
</Select>
<p className="text-xs text-muted-foreground">
{config.mode === "api"
? t("settings.ai.modeApiHint")
: t("settings.ai.modeLocalHint")}
</p>
</div>
</SettingsCard>
</SettingsSection>
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.mode")}</FieldLabel>
<Select
onValueChange={(value) => set("mode", value as AiMode)}
value={config.mode}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectPopup>
<SelectItem value="auto">
{t("settings.ai.modeAuto")}
</SelectItem>
<SelectItem value="api">{t("settings.ai.modeApi")}</SelectItem>
<SelectItem value="local">
{t("settings.ai.modeLocal")}
</SelectItem>
<SelectItem value="off">{t("settings.ai.modeOff")}</SelectItem>
</SelectPopup>
</Select>
<p className="text-xs text-muted-foreground">
{t(`settings.ai.mode${modeHintSuffix}Hint`)}
</p>
</div>
</SettingsFrame>
{config.mode === "api" ? (
<SettingsSection
{config.mode === "off" ? (
<SettingsFrame
description={t("settings.ai.offDescription")}
title={t("settings.ai.offTitle")}
>
<p className="text-sm text-muted-foreground">
{t("settings.ai.offNote")}
</p>
</SettingsFrame>
) : config.mode !== "local" ? (
// API and Automatic both configure a cloud provider (Automatic falls
// back to local Ollama when no key is set).
<SettingsFrame
bodyClassName="space-y-5"
description={t("settings.ai.providerDescription")}
title={t("settings.ai.providerTitle")}
>
<SettingsCard className="space-y-5 p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.provider")}</FieldLabel>
<Select
@@ -377,15 +396,14 @@ export function AIPanel() {
</SelectPopup>
</Select>
</div>
</SettingsCard>
</SettingsSection>
</SettingsFrame>
) : (
<SettingsSection
<SettingsFrame
bodyClassName="space-y-5"
description={t("settings.ai.localDescription")}
title={t("settings.ai.localTitle")}
>
<SettingsCard className="space-y-5 p-5">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.ollamaBaseUrl")}</FieldLabel>
<Input
@@ -414,40 +432,40 @@ export function AIPanel() {
? t("settings.ai.testing")
: t("settings.ai.testConnection")}
</Button>
</SettingsCard>
</SettingsSection>
</SettingsFrame>
)}
<SettingsSection
description={t("settings.ai.veilDescription")}
title={t("settings.ai.veilTitle")}
>
<SettingsCard className="space-y-4 p-5">
{config.mode !== "off" ? (
<SettingsFrame
bodyClassName="space-y-4"
description={t("settings.ai.veilDescription")}
title={t("settings.ai.veilTitle")}
>
<div className="space-y-1.5">
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
<Select
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
value={config.veilLevel}
>
<SelectTrigger className="sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{VEIL_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{t(`settings.ai.veilLevels.${level}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{config.mode === "local"
? t("settings.ai.veilLocalNote")
: t("settings.ai.veilApiNote")}
</p>
</SettingsCard>
</SettingsSection>
<FieldLabel>{t("settings.ai.veilLevel")}</FieldLabel>
<Select
onValueChange={(value) => set("veilLevel", value as VeilLevel)}
value={config.veilLevel}
>
<SelectTrigger className="sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectPopup>
{VEIL_LEVELS.map((level) => (
<SelectItem key={level} value={level}>
{t(`settings.ai.veilLevels.${level}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<p className="text-xs text-muted-foreground">
{config.mode === "local"
? t("settings.ai.veilLocalNote")
: t("settings.ai.veilApiNote")}
</p>
</SettingsFrame>
) : null}
{dirty ? (
<div className="sticky bottom-4 z-10">
@@ -176,7 +176,7 @@ export function SigningPanel() {
description={t("settings.network.description")}
title={t("settings.network.title")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-5">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-5">
<div className="min-w-0 space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
@@ -251,7 +251,7 @@ export function SigningPanel() {
description={t("settings.signing.backupDescription")}
title={t("settings.signing.backupTitle")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.signing.backupLabel")}
+58 -18
View File
@@ -5,9 +5,53 @@ import { useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
Card,
CardFrame,
CardFrameAction,
CardFrameDescription,
CardFrameHeader,
CardFramePanel,
CardFrameTitle,
} from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
// A settings section rendered inside the COSS "frame" surface: a titled header
// (with optional description + action) sitting above a padded body. Replaces the
// old plain-`div` SettingsCard pattern so every settings panel shares one framed
// look. Use `bodyClassName` to control the body layout (spacing/grid).
export function SettingsFrame({
title,
description,
action,
children,
className,
bodyClassName,
}: {
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: string;
}) {
return (
<CardFrame className={className}>
<CardFrameHeader className="border-b border-border/60">
<CardFrameTitle className="text-base">{title}</CardFrameTitle>
{description ? (
<CardFrameDescription>{description}</CardFrameDescription>
) : null}
{action ? <CardFrameAction>{action}</CardFrameAction> : null}
</CardFrameHeader>
<CardFramePanel className={bodyClassName}>{children}</CardFramePanel>
</CardFrame>
);
}
// Back-compat wrapper: existing panels compose with SettingsSection, which now
// renders through the COSS frame surface so the whole settings page shares one
// framed look. The body keeps vertical spacing between stacked children.
export function SettingsSection({
title,
description,
@@ -20,21 +64,21 @@ export function SettingsSection({
children: ReactNode;
}) {
return (
<section className="space-y-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold tracking-tight">{title}</h2>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
<SettingsFrame
action={action}
bodyClassName="space-y-4"
description={description}
title={title}
>
{children}
</section>
</SettingsFrame>
);
}
// A card surface used inside settings panels. Rendering a real COSS `Card` (with
// `data-slot="card"`) keeps settings cards consistent with the rest of the app
// and lets them pick up the frame's card treatment. `Card` is `flex flex-col`,
// so row layouts must pass `flex-row` in their className.
export function SettingsCard({
className,
children,
@@ -42,11 +86,7 @@ export function SettingsCard({
className?: string;
children: ReactNode;
}) {
return (
<div className={cn("rounded-2xl border border-border bg-card/30", className)}>
{children}
</div>
);
return <Card className={className}>{children}</Card>;
}
export function ToggleRow({
@@ -64,7 +104,7 @@ export function ToggleRow({
onCheckedChange?: (checked: boolean) => void;
}) {
return (
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
<SettingsCard className="flex flex-row items-center justify-between gap-4 px-4 py-3.5">
<div className="space-y-0.5">
<p className="text-sm font-medium">{title}</p>
{description ? (
@@ -375,7 +375,7 @@ export function ProfilePanel() {
description={t("settings.profile.dangerZoneDescription")}
title={t("settings.profile.dangerZone")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<SettingsCard className="flex flex-row items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">
{t("settings.profile.deleteAccount")}
+20
View File
@@ -136,6 +136,26 @@ export function CardFrameFooter({
});
}
// The padded body of a frame: content that sits between the frame header and
// footer. Mirrors the other CardFrame* subcomponents (useRender + data-slot) so
// a frame can be composed entirely from primitives instead of a raw <div>.
export function CardFramePanel({
className,
render,
...props
}: useRender.ComponentProps<"div">): React.ReactElement {
const defaultProps = {
className: cn("p-5", className),
"data-slot": "card-frame-panel",
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, props),
render,
});
}
export function CardHeader({
className,
render,
+32 -2
View File
@@ -74,13 +74,43 @@ export function useWalletSync(fileNumber: string | null | undefined) {
};
}, [state, update]);
// Authoritatively resolve link status at submit time. The `linked` state above
// is populated asynchronously by the effect, so a fast save (or a transient
// failure that collapsed it to false) can leave a wallet-backed patient looking
// unlinked. Callers await this before deciding whether to show the wallet step,
// so the decision is never made on an unresolved check.
const ensureLinked = useCallback(async (): Promise<boolean> => {
if (!fileNumber) {
setLinked(false);
return false;
}
setChecking(true);
try {
await getWalletLink(fileNumber);
setLinked(true);
return true;
} catch {
setLinked(false);
return false;
} finally {
setChecking(false);
}
}, [fileNumber]);
const push = useCallback(
async (changes: string[]) => {
if (!fileNumber) return;
// Never push an empty/whitespace change set — the backend rejects it (400).
const clean = changes.map((c) => c.trim()).filter(Boolean);
if (clean.length === 0) {
setError("generic");
setState("error");
return;
}
setError(null);
setState("pending");
try {
const created = await pushWalletUpdate({ fileNumber, changes });
const created = await pushWalletUpdate({ fileNumber, changes: clean });
setUpdate(created);
} catch (err) {
setError(err instanceof ApiError ? err.message : "generic");
@@ -96,7 +126,7 @@ export function useWalletSync(fileNumber: string | null | undefined) {
setError(null);
}, []);
return { linked, checking, state, update, error, push, reset };
return { linked, checking, state, update, error, ensureLinked, push, reset };
}
export type UseWalletSync = ReturnType<typeof useWalletSync>;
@@ -60,6 +60,9 @@ export function WalletSyncStep({
const { t } = useTranslation();
const { state, update, error, push } = sync;
const status = update?.status ?? "pending";
// The summary is the human-readable change the patient approves. Guard against
// an empty one (the backend 400s on empty changes) with a translated fallback.
const changeSummary = summary.trim() || t("walletSync.summaryFallback");
return (
<>
@@ -79,7 +82,7 @@ export function WalletSyncStep({
{t("walletSync.changesLabel")}
</span>
<div className="rounded-lg border bg-muted/50 px-3 py-2 text-foreground text-sm">
{summary}
{changeSummary}
</div>
</div>
{error && (
@@ -119,7 +122,7 @@ export function WalletSyncStep({
<Button onClick={onDone} type="button" variant="outline">
{t("walletSync.skip")}
</Button>
<Button onClick={() => push([summary])} type="button">
<Button onClick={() => push([changeSummary])} type="button">
<Send className="size-4" />
{t("walletSync.send")}
</Button>
+3 -1
View File
@@ -5,7 +5,9 @@ import type { Effort } from "@/lib/ai-models";
// saved to /api/ai/config. Provider API keys are write-only: they are never
// returned, only `apiKeySet` reports which providers have a stored key.
export type AiMode = "api" | "local";
// "auto" auto-picks a cloud provider when a key is set, else falls back to local
// Ollama; "off" disables the assistant entirely.
export type AiMode = "api" | "local" | "auto" | "off";
export type ApiProvider = "openai" | "anthropic" | "gemini";
export type VeilLevel = "off" | "names" | "full";
+16 -2
View File
@@ -376,6 +376,7 @@
"failedTitle": "تعذّر حذف المريض",
"failedBody": "يرجى المحاولة مرة أخرى."
},
"filterLabel": "تصفية",
"filterStatus": "تصفية حسب الحالة",
"allStatuses": "كل الحالات",
"moreActions": "إجراءات إضافية"
@@ -1149,7 +1150,12 @@
"title": "اربط نموذج ذكاء اصطناعي للبدء",
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
"action": "فتح إعدادات الذكاء الاصطناعي",
"dismiss": "تجاهل"
"dismiss": "تجاهل",
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
},
"card": {
"unsupported": "بطاقة غير مدعومة"
},
"input": {
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
@@ -2111,6 +2117,13 @@
"modeLocal": "نموذج محلي (Ollama)",
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
"modeAuto": "تلقائي",
"modeOff": "إيقاف",
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
"offTitle": "المساعد مُوقف",
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
"providerTitle": "المزوّد ومفتاح API",
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
"provider": "المزوّد",
@@ -2244,6 +2257,7 @@
"prescription": "وصفة جديدة: {{drug}}",
"demographics": "تم تحديث البيانات الديموغرافية",
"note": "ملاحظة سريرية جديدة"
}
},
"summaryFallback": "تم تحديث السجل"
}
}
+16 -2
View File
@@ -364,6 +364,7 @@
"failedTitle": "Patient konnte nicht gelöscht werden",
"failedBody": "Bitte versuchen Sie es erneut."
},
"filterLabel": "Filter",
"filterStatus": "Nach Status filtern",
"allStatuses": "Alle Status",
"moreActions": "Weitere Aktionen"
@@ -1133,7 +1134,12 @@
"title": "Verbinden Sie ein KI-Modell, um zu beginnen",
"body": "Es ist noch kein KI-Anbieter eingerichtet. Fügen Sie einen API-Schlüssel hinzu oder verweisen Sie temetro auf ein lokales Ollama-Modell, damit der Assistent antworten kann.",
"action": "KI-Einstellungen öffnen",
"dismiss": "Ausblenden"
"dismiss": "Ausblenden",
"offTitle": "Der KI-Assistent ist ausgeschaltet",
"offBody": "KI ist unter Einstellungen → KI auf Aus gestellt. Schalten Sie sie ein, um den Assistenten zu nutzen."
},
"card": {
"unsupported": "Nicht unterstützte Karte"
},
"input": {
"placeholder": "Fragen Sie etwas oder tippen Sie /patient 10293",
@@ -2091,6 +2097,13 @@
"modeLocal": "Lokales Modell (Ollama)",
"modeApiHint": "Anfragen gehen an Ihren gewählten Anbieter. Patientenkennungen werden von Veil anonymisiert, bevor sie das System verlassen.",
"modeLocalHint": "Anfragen laufen gegen ein Modell auf Ihrer eigenen Infrastruktur. Keine Patientendaten verlassen die Klinik.",
"modeAuto": "Automatisch",
"modeOff": "Aus",
"modeAutoHint": "Verwendet einen Cloud-API-Schlüssel, sofern vorhanden, andernfalls Ihr lokales Ollama-Modell.",
"modeOffHint": "Der KI-Assistent ist deaktiviert. Es werden keine Anfragen gesendet.",
"offTitle": "Assistent aus",
"offDescription": "Der KI-Assistent ist für Ihr Konto ausgeschaltet.",
"offNote": "Wählen Sie oben Automatisch, Cloud-API-Schlüssel oder Lokales Modell, um den Assistenten zu aktivieren.",
"providerTitle": "Anbieter & API-Schlüssel",
"providerDescription": "Ihr Schlüssel wird verschlüsselt gespeichert und nie wieder angezeigt. Sie können einen Schlüssel pro Anbieter speichern und zwischen ihnen wechseln.",
"provider": "Anbieter",
@@ -2224,6 +2237,7 @@
"prescription": "Neues Rezept: {{drug}}",
"demographics": "Stammdaten aktualisiert",
"note": "Neue klinische Notiz"
}
},
"summaryFallback": "Datensatz aktualisiert"
}
}
+16 -2
View File
@@ -364,6 +364,7 @@
"failedTitle": "Couldn't delete patient",
"failedBody": "Please try again."
},
"filterLabel": "Filter",
"filterStatus": "Filter by status",
"allStatuses": "All statuses",
"moreActions": "More actions"
@@ -1133,7 +1134,12 @@
"title": "Connect an AI model to get started",
"body": "No AI provider is set up yet. Add an API key or point temetro at a local Ollama model so the assistant can answer.",
"action": "Open AI settings",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"offTitle": "The AI assistant is turned off",
"offBody": "AI is set to Off in Settings → AI. Turn it on to use the assistant."
},
"card": {
"unsupported": "Unsupported card"
},
"input": {
"placeholder": "Ask anything, or type /patient 10293",
@@ -2089,8 +2095,15 @@
"mode": "Mode",
"modeApi": "Cloud API key",
"modeLocal": "Local model (Ollama)",
"modeAuto": "Automatic",
"modeOff": "Off",
"modeApiHint": "Requests go to your chosen provider. Patient identifiers are de-identified by Veil before they leave.",
"modeLocalHint": "Requests run against a model on your own infrastructure. No patient data leaves the clinic.",
"modeAutoHint": "Uses a cloud API key when one is set, otherwise falls back to your local Ollama model.",
"modeOffHint": "The AI assistant is disabled. No requests are sent.",
"offTitle": "Assistant off",
"offDescription": "The AI assistant is turned off for your account.",
"offNote": "Choose Automatic, Cloud API key, or Local model above to enable the assistant.",
"providerTitle": "Provider & API key",
"providerDescription": "Your key is encrypted at rest and never shown again. You can store a key per provider and switch between them.",
"provider": "Provider",
@@ -2224,6 +2237,7 @@
"prescription": "New prescription: {{drug}}",
"demographics": "Demographics updated",
"note": "New clinical note"
}
},
"summaryFallback": "Record updated"
}
}
+16 -2
View File
@@ -364,6 +364,7 @@
"failedTitle": "Impossible de supprimer le patient",
"failedBody": "Veuillez réessayer."
},
"filterLabel": "Filtre",
"filterStatus": "Filtrer par statut",
"allStatuses": "Tous les statuts",
"moreActions": "Plus dactions"
@@ -1133,7 +1134,12 @@
"title": "Connectez un modèle d'IA pour commencer",
"body": "Aucun fournisseur d'IA n'est encore configuré. Ajoutez une clé API ou pointez temetro vers un modèle Ollama local pour que l'assistant puisse répondre.",
"action": "Ouvrir les paramètres d'IA",
"dismiss": "Ignorer"
"dismiss": "Ignorer",
"offTitle": "L'assistant IA est désactivé",
"offBody": "L'IA est réglée sur Désactivée dans Paramètres → IA. Activez-la pour utiliser l'assistant."
},
"card": {
"unsupported": "Carte non prise en charge"
},
"input": {
"placeholder": "Demandez n'importe quoi, ou tapez /patient 10293",
@@ -2091,6 +2097,13 @@
"modeLocal": "Modèle local (Ollama)",
"modeApiHint": "Les requêtes vont vers le fournisseur que vous avez choisi. Les identifiants des patients sont dé-identifiés par Veil avant leur départ.",
"modeLocalHint": "Les requêtes s'exécutent sur un modèle de votre propre infrastructure. Aucune donnée patient ne quitte la clinique.",
"modeAuto": "Automatique",
"modeOff": "Désactivé",
"modeAutoHint": "Utilise une clé API cloud si elle est définie, sinon revient à votre modèle Ollama local.",
"modeOffHint": "L'assistant IA est désactivé. Aucune requête n'est envoyée.",
"offTitle": "Assistant désactivé",
"offDescription": "L'assistant IA est désactivé pour votre compte.",
"offNote": "Choisissez Automatique, Clé API cloud ou Modèle local ci-dessus pour activer l'assistant.",
"providerTitle": "Fournisseur & clé API",
"providerDescription": "Votre clé est chiffrée au repos et n'est plus jamais affichée. Vous pouvez stocker une clé par fournisseur et basculer entre elles.",
"provider": "Fournisseur",
@@ -2224,6 +2237,7 @@
"prescription": "Nouvelle ordonnance : {{drug}}",
"demographics": "Données démographiques mises à jour",
"note": "Nouvelle note clinique"
}
},
"summaryFallback": "Dossier mis à jour"
}
}
+16 -2
View File
@@ -364,6 +364,7 @@
"failedTitle": "Bukaanka lama tirtiri karin",
"failedBody": "Fadlan isku day mar kale."
},
"filterLabel": "Kala sooc",
"filterStatus": "Ku kala sooc xaaladda",
"allStatuses": "Dhammaan xaaladaha",
"moreActions": "Ficillo dheeraad ah"
@@ -1133,7 +1134,12 @@
"title": "Ku xir moodeel AI si aad u bilowdo",
"body": "Weli bixiye AI lama dejin. Ku dar furaha API ama ku tilmaam temetro moodeel Ollama oo maxalli ah si kaaliyuhu u jawaabo.",
"action": "Fur dejinta AI",
"dismiss": "Rid"
"dismiss": "Rid",
"offTitle": "Kaaliyaha AI waa la damiyay",
"offBody": "AI waxaa loo dejiyay Damin gudaha Settings → AI. Shid si aad u isticmaasho kaaliyaha."
},
"card": {
"unsupported": "Kaadh aan la taageerin"
},
"input": {
"placeholder": "Waydii wax kasta, ama qor /patient 10293",
@@ -2091,6 +2097,13 @@
"modeLocal": "Moodeel maxalli ah (Ollama)",
"modeApiHint": "Codsiyada waxay u socdaan bixiyahaaga aad dooratay. Aqoonsiyada bukaanka waxaa qariya Veil ka hor inta aysan bixin.",
"modeLocalHint": "Codsiyada waxay ku socdaan moodeel kaabayaashaada ah. Xog bukaan kama baxdo rugta.",
"modeAuto": "Toos ah",
"modeOff": "Damin",
"modeAutoHint": "Wuxuu isticmaalaa furaha API-ga daruuraha marka la dejiyo, haddii kale wuxuu ku noqdaa moodelkaaga Ollama ee gudaha.",
"modeOffHint": "Kaaliyaha AI waa la damiyay. Wax codsi ah lama dirayo.",
"offTitle": "Kaaliyuhu waa damsan yahay",
"offDescription": "Kaaliyaha AI waa laga damiyay akoonkaaga.",
"offNote": "Dooro Toos ah, Furaha API daruuraha, ama Moodel Gudaha kor si aad u shidid kaaliyaha.",
"providerTitle": "Bixiye & furaha API",
"providerDescription": "Furahaaga waa la shifraa marka la kaydiyo mana dib loo muujiyo. Waxaad kaydin kartaa fur bixiye kasta oo aad kala beddeli kartaa.",
"provider": "Bixiye",
@@ -2224,6 +2237,7 @@
"prescription": "Rijeeto cusub: {{drug}}",
"demographics": "Xogta bukaanka la cusboonaysiiyay",
"note": "Qoraal caafimaad cusub"
}
},
"summaryFallback": "Diiwaanka waa la cusboonaysiiyay"
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "frontend",
"version": "0.13.1",
"version": "0.14.2",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint",
"check-locales": "node scripts/check-locales.mjs"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.13.1",
"version": "0.14.2",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"