Compare commits

...

4 Commits

Author SHA1 Message Date
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
Khalid Abdi 17209a2cf1 chore: release v0.13.1
Bump version to 0.13.1 across root/backend/frontend and add the 0.13.1
CHANGELOG entry (COSS checkbox/table on the web, wallet-app home sheets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:16:07 +03:00
Khalid Abdi bd8fdfadda frontend: COSS checkbox/table + fewer toolbar/sheet buttons
- Invoice dialog: the Back-date / Due-date toggles now use the COSS Checkbox
  instead of raw, misshapen native checkboxes.
- Patients page: rebuilt the list as a COSS Table in a CardFrame, and moved
  "Import from a patient app" into a ⋯ overflow menu so the toolbar keeps one
  primary "Add patient" CTA.
- Patient detail sheet: collapsed the five header buttons + delete into a
  primary Edit action plus a ⋯ More menu (Download summary, Record visit,
  Transfer, Push to wallet, and a destructive Delete).
- Added the COSS table + checkbox primitives; new i18n keys across all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:11:06 +03:00
26 changed files with 830 additions and 284 deletions
+54
View File
@@ -7,6 +7,60 @@ for how releases are cut and published.
## [Unreleased]
## [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
- **New Invoice checkboxes.** The Back-date / Due-date toggles now use the COSS `Checkbox`
(`frontend/components/ui/checkbox.tsx`) instead of raw, misshapen native checkboxes.
- **Patients page toolbar & table.** The list is now a COSS **Table in a `CardFrame`**
(`frontend/components/ui/table.tsx`), and the secondary "Import from a patient app" action moved
into a `⋯` overflow menu so the toolbar keeps a single primary "Add patient" CTA.
- **Patient detail sheet header.** The five action buttons plus delete collapse into a primary
**Edit** button and a `⋯ More` menu (Download summary, Record visit, Transfer, Push to wallet, and
a destructive Delete).
### Wallet app
- **Home quick actions open sheets.** "Share record" now opens a bottom sheet with a scannable **QR**
of the wallet number (`react-native-qrcode-svg`); "My wallet" opens a bottom sheet with copyable
wallet number / fingerprint / algorithm; and the duplicate "Notifications" action (the header
already has a bell) is replaced with **Scan**.
## [0.13.0] — 2026-07-12
### Added
+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.0",
"version": "0.14.0",
"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";
+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;
})}
@@ -12,6 +12,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Checkbox } from "@/components/ui/checkbox";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import {
Dialog,
@@ -367,11 +368,14 @@ export function InvoiceFormDialog({
<div className="flex flex-col gap-1.5">
<span className="flex items-center justify-between gap-2 text-muted-foreground text-xs">
{t("invoices.dialog.issued")}
<label className="flex items-center gap-1 text-[11px]">
<input
<label
className="flex items-center gap-1.5 text-[11px]"
htmlFor="invoice-backdate"
>
<Checkbox
checked={allowBackdate}
onChange={(e) => setAllowBackdate(e.target.checked)}
type="checkbox"
id="invoice-backdate"
onCheckedChange={(checked) => setAllowBackdate(checked)}
/>
{t("invoices.dialog.backdate")}
</label>
@@ -385,11 +389,10 @@ export function InvoiceFormDialog({
<div className="flex flex-col gap-1.5">
<span className="flex items-center justify-between text-muted-foreground text-xs">
{t("invoices.dialog.due")}
<input
<Checkbox
aria-label={t("invoices.dialog.due")}
checked={hasDue}
onChange={(e) => setHasDue(e.target.checked)}
type="checkbox"
onCheckedChange={(checked) => setHasDue(checked)}
/>
</span>
{hasDue ? (
+63 -58
View File
@@ -7,6 +7,7 @@ import {
type LucideIcon,
ListTodo,
Mic,
MoreHorizontal,
Network,
NotebookPen,
Pencil,
@@ -29,6 +30,13 @@ import {
import { printPatientSummary } from "@/lib/patient-pdf";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
import {
Dialog,
DialogClose,
@@ -295,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}
@@ -302,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 && (
@@ -315,64 +378,6 @@ export function PatientDetail({
)}
</div>
</div>
{/* Actions — their own wrapping row beneath the identity. */}
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => printPatientSummary(patient, t)}
size="sm"
type="button"
variant="outline"
>
<FileDown className="size-4" />
{t("patientCard.exportPdf")}
</Button>
{onTransfer && (
<Button
onClick={onTransfer}
size="sm"
type="button"
variant="outline"
>
<ArrowLeftRight className="size-4" />
{t("patients.transfer.action")}
</Button>
)}
{onScribe && (
<Button onClick={onScribe} size="sm" type="button" variant="outline">
<Mic className="size-4" />
{t("scribe.recordVisit")}
</Button>
)}
{onEdit && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
<Pencil className="size-4" />
{t("patientCard.edit")}
</Button>
)}
{onWalletPush && (
<Button
onClick={onWalletPush}
size="sm"
type="button"
variant="outline"
>
<Send className="size-4" />
{t("walletPush.action")}
</Button>
)}
{onDelete && (
<Button
aria-label={t("patients.delete.action")}
className="ms-auto"
onClick={onDelete}
size="sm"
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
</Button>
)}
</div>
</div>
<Section title={t("patientCard.overview")}>
+126 -82
View File
@@ -1,6 +1,6 @@
"use client";
import { Plus, Search, Smartphone } from "lucide-react";
import { MoreHorizontal, Plus, Search, Smartphone } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -11,8 +11,15 @@ import { ImportFromWalletDialog } from "@/components/patients/import-from-wallet
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CardFrame } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ListPagination } from "@/components/ui/list-pagination";
import {
Menu,
MenuItem,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
import {
Select,
SelectItem,
@@ -20,6 +27,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { listPatients, type Patient } from "@/lib/patients";
type StatusFilter = "all" | Patient["status"];
@@ -158,38 +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={() => setImportOpen(true)}
type="button"
variant="outline"
>
<Smartphone className="size-4" />
{t("patients.importFromApp")}
</Button>
<Button
className="rounded-3xl"
onClick={() => {
@@ -201,58 +184,119 @@ export function PatientsView() {
<Plus className="size-4" />
{t("patients.add")}
</Button>
{/* Secondary actions tuck into an overflow menu so the toolbar keeps a
single primary CTA. */}
<Menu>
<MenuTrigger
render={
<Button
aria-label={t("patients.moreActions")}
className="rounded-full"
size="icon"
type="button"
variant="outline"
/>
}
>
<MoreHorizontal className="size-4" />
</MenuTrigger>
<MenuPopup align="end">
<MenuItem onClick={() => setImportOpen(true)}>
<Smartphone className="size-4" />
{t("patients.importFromApp")}
</MenuItem>
</MenuPopup>
</Menu>
</div>
</div>
<div className="mt-8 overflow-hidden rounded-2xl border border-border bg-card/30">
<table className="w-full text-sm">
<thead>
<tr className="border-border border-b text-start text-xs text-muted-foreground uppercase">
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
<th className="px-4 py-3 font-medium">
{/* 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>
<TableHead className="ps-4 text-xs uppercase">
{t("patients.columns.name")}
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.mrn")}
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.ageSex")}
</th>
<th className="px-4 py-3 font-medium">
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.status")}
</th>
<th className="px-4 py-3 font-medium">
</TableHead>
<TableHead className="text-xs uppercase">
{t("patients.columns.lastSeen")}
</th>
<th className="px-4 py-3 font-medium">
</TableHead>
<TableHead className="pe-4 text-xs uppercase">
{t("patients.columns.allergies")}
</th>
</tr>
</thead>
<tbody>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<tr>
<td
className="px-4 py-10 text-center text-muted-foreground"
<TableRow>
<TableCell
className="py-10 text-center text-muted-foreground"
colSpan={6}
>
{t("patients.loading")}
</td>
</tr>
</TableCell>
</TableRow>
) : loadError ? (
<tr>
<td className="px-4 py-10 text-center text-destructive" colSpan={6}>
<TableRow>
<TableCell
className="py-10 text-center text-destructive"
colSpan={6}
>
{loadError}
</td>
</tr>
</TableCell>
</TableRow>
) : patients.length === 0 ? (
<tr>
<td
className="px-4 py-10 text-center text-muted-foreground"
<TableRow>
<TableCell
className="py-10 text-center text-muted-foreground"
colSpan={6}
>
{t("patients.empty")}
</td>
</tr>
</TableCell>
</TableRow>
) : (
pageRows.map((p) => (
<tr
className="cursor-pointer border-border/50 border-b transition-colors last:border-0 hover:bg-accent/50"
<TableRow
className="cursor-pointer"
key={p.fileNumber}
onClick={() => open(p.fileNumber)}
onKeyDown={(event) => {
@@ -264,7 +308,7 @@ export function PatientsView() {
role="button"
tabIndex={0}
>
<td className="px-4 py-3 font-medium text-foreground">
<TableCell className="ps-4 py-3 font-medium text-foreground">
<span className="flex items-center gap-2">
{p.name}
<AiBadge source={p.source} />
@@ -274,30 +318,30 @@ export function PatientsView() {
</Badge>
) : null}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{p.fileNumber}
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{p.age} · {p.sex}
</td>
<td className="px-4 py-3">
</TableCell>
<TableCell className="py-3">
<Badge variant={statusVariant[p.status]}>
{t(`patients.status.${p.status}`)}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{p.encounters[0]?.date ?? "—"}
</td>
<td className="px-4 py-3 text-muted-foreground">
</TableCell>
<TableCell className="pe-4 py-3 text-muted-foreground">
{p.allergies.length || "—"}
</td>
</tr>
</TableCell>
</TableRow>
))
)}
</tbody>
</table>
</div>
</TableBody>
</Table>
</CardFrame>
{!loading && !loadError ? (
<ListPagination
+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">
+50 -11
View File
@@ -6,8 +6,51 @@ import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import {
CardFrame,
CardFrameAction,
CardFrameDescription,
CardFrameHeader,
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>
<div className={cn("p-5", bodyClassName)}>{children}</div>
</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,18 +63,14 @@ 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>
);
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
import type React from "react";
import { cn } from "@/lib/utils";
export function Checkbox({
className,
...props
}: CheckboxPrimitive.Root.Props): React.ReactElement {
return (
<CheckboxPrimitive.Root
className={cn(
"relative inline-flex size-4.5 shrink-0 items-center justify-center rounded-[.25rem] border border-input bg-background not-dark:bg-clip-padding shadow-xs/5 outline-none ring-ring transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[3px] not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_1px_--theme(--color-black/4%)] focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-background aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/48 data-disabled:cursor-not-allowed data-disabled:opacity-64 sm:size-4 dark:not-data-checked:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-data-checked:not-aria-invalid:before:shadow-[0_-1px_--theme(--color-white/6%)] [[data-disabled],[data-checked],[aria-invalid]]:shadow-none",
className,
)}
data-slot="checkbox"
{...props}
>
<CheckboxPrimitive.Indicator
className="absolute -inset-px flex items-center justify-center rounded-[.25rem] text-primary-foreground data-unchecked:hidden data-checked:bg-primary data-indeterminate:text-foreground"
data-slot="checkbox-indicator"
render={(
props: React.ComponentProps<"span">,
state: CheckboxPrimitive.Indicator.State,
) => (
<span {...props}>
{state.indeterminate ? (
<svg
aria-hidden="true"
className="size-3.5 sm:size-3"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.252 12h13.496" />
</svg>
) : (
<svg
aria-hidden="true"
className="size-3.5 sm:size-3"
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="3"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M5.252 12.7 10.2 18.63 18.748 5.37" />
</svg>
)}
</span>
)}
/>
</CheckboxPrimitive.Root>
);
}
export { CheckboxPrimitive };
+151
View File
@@ -0,0 +1,151 @@
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import type React from "react";
import { cn } from "@/lib/utils";
export type TableVariant = "default" | "card";
export type TableProps = React.ComponentProps<"table"> & {
variant?: TableVariant;
render?: useRender.ComponentProps<"div">["render"];
};
export function Table({
className,
variant = "default",
render,
...props
}: TableProps): React.ReactElement {
const defaultProps = {
children: (
<table
className={cn(
"w-full caption-bottom in-data-[variant=card]:border-separate in-data-[variant=card]:border-spacing-0 text-sm",
className,
)}
data-slot="table"
{...props}
/>
),
className: "relative w-full overflow-x-auto",
"data-slot": "table-container",
"data-variant": variant,
};
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(defaultProps, {}),
render,
});
}
export function TableHeader({
className,
...props
}: React.ComponentProps<"thead">): React.ReactElement {
return (
<thead
className={cn("[&_tr]:border-b", className)}
data-slot="table-header"
{...props}
/>
);
}
export function TableBody({
className,
...props
}: React.ComponentProps<"tbody">): React.ReactElement {
return (
<tbody
className={cn(
"relative in-data-[variant=card]:rounded-xl in-data-[variant=card]:shadow-xs/5 before:pointer-events-none before:absolute before:inset-px not-in-data-[variant=card]:before:hidden before:rounded-[calc(var(--radius-xl)-1px)] before:shadow-[0_1px_--theme(--color-black/4%)] dark:before:shadow-[0_-1px_--theme(--color-white/8%)] [&_tr:last-child]:border-0 in-data-[variant=card]:*:[tr]:border-0 in-data-[variant=card]:*:[tr]:*:[td]:border-b in-data-[variant=card]:*:[tr]:*:[td]:bg-card in-data-[variant=card]:*:[tr]:first:*:[td]:first:rounded-ss-xl in-data-[variant=card]:*:[tr]:*:[td]:first:border-s in-data-[variant=card]:*:[tr]:first:*:[td]:border-t in-data-[variant=card]:*:[tr]:last:*:[td]:last:rounded-ee-xl in-data-[variant=card]:*:[tr]:*:[td]:last:border-e in-data-[variant=card]:*:[tr]:first:*:[td]:last:rounded-se-xl in-data-[variant=card]:*:[tr]:last:*:[td]:first:rounded-es-xl in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_4%)] dark:in-data-[variant=card]:*:[tr]:data-[state=selected]:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_4%)] dark:in-data-[variant=card]:*:[tr]:hover:*:[td]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)]",
className,
)}
data-slot="table-body"
{...props}
/>
);
}
export function TableFooter({
className,
...props
}: React.ComponentProps<"tfoot">): React.ReactElement {
return (
<tfoot
className={cn(
"border-t in-data-[variant=card]:border-none bg-transparent not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-black)_2%)] font-medium dark:not-in-data-[variant=card]:bg-[color-mix(in_srgb,var(--card),var(--color-white)_2%)] [&>tr]:last:border-b-0",
className,
)}
data-slot="table-footer"
{...props}
/>
);
}
export function TableRow({
className,
...props
}: React.ComponentProps<"tr">): React.ReactElement {
return (
<tr
className={cn(
"relative border-b not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-black)_2%)] not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-black)_4%)] dark:not-in-data-[variant=card]:data-[state=selected]:bg-[color-mix(in_srgb,var(--background),var(--color-white)_4%)] dark:not-in-data-[variant=card]:hover:bg-[color-mix(in_srgb,var(--background),var(--color-white)_2%)]",
className,
)}
data-slot="table-row"
{...props}
/>
);
}
export function TableHead({
className,
...props
}: React.ComponentProps<"th">): React.ReactElement {
return (
<th
className={cn(
"h-10 whitespace-nowrap px-2.5 text-left align-middle font-medium text-muted-foreground leading-none has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
className,
)}
data-slot="table-head"
{...props}
/>
);
}
export function TableCell({
className,
...props
}: React.ComponentProps<"td">): React.ReactElement {
return (
<td
className={cn(
"whitespace-nowrap bg-clip-padding p-2.5 in-data-[slot=table-footer]:py-3.5 align-middle leading-none in-data-[variant=card]:first:ps-[calc(--spacing(2.5)-1px)] in-data-[variant=card]:last:pe-[calc(--spacing(2.5)-1px)] has-[[role=checkbox]]:w-px last:has-[[role=checkbox]]:ps-0 first:has-[[role=checkbox]]:pe-0",
className,
)}
data-slot="table-cell"
{...props}
/>
);
}
export function TableCaption({
className,
...props
}: React.ComponentProps<"caption">): React.ReactElement {
return (
<caption
className={cn(
"in-data-[variant=card]:my-4 mt-4 text-muted-foreground text-sm",
className,
)}
data-slot="table-caption"
{...props}
/>
);
}
+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";
+18 -3
View File
@@ -376,8 +376,10 @@
"failedTitle": "تعذّر حذف المريض",
"failedBody": "يرجى المحاولة مرة أخرى."
},
"filterLabel": "تصفية",
"filterStatus": "تصفية حسب الحالة",
"allStatuses": "كل الحالات"
"allStatuses": "كل الحالات",
"moreActions": "إجراءات إضافية"
},
"appointments": {
"title": "المواعيد والجدول",
@@ -1148,7 +1150,12 @@
"title": "اربط نموذج ذكاء اصطناعي للبدء",
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
"action": "فتح إعدادات الذكاء الاصطناعي",
"dismiss": "تجاهل"
"dismiss": "تجاهل",
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
},
"card": {
"unsupported": "بطاقة غير مدعومة"
},
"input": {
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
@@ -1555,7 +1562,8 @@
"mild": "خفيف",
"moderate": "متوسط",
"severe": "شديد"
}
},
"moreActions": "إجراءات إضافية"
},
"patientForm": {
"editTitle": "تعديل السجل",
@@ -2109,6 +2117,13 @@
"modeLocal": "نموذج محلي (Ollama)",
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
"modeAuto": "تلقائي",
"modeOff": "إيقاف",
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
"offTitle": "المساعد مُوقف",
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
"providerTitle": "المزوّد ومفتاح API",
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
"provider": "المزوّد",
+18 -3
View File
@@ -364,8 +364,10 @@
"failedTitle": "Patient konnte nicht gelöscht werden",
"failedBody": "Bitte versuchen Sie es erneut."
},
"filterLabel": "Filter",
"filterStatus": "Nach Status filtern",
"allStatuses": "Alle Status"
"allStatuses": "Alle Status",
"moreActions": "Weitere Aktionen"
},
"appointments": {
"title": "Termine & Zeitplan",
@@ -1132,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",
@@ -1535,7 +1542,8 @@
"mild": "Leicht",
"moderate": "Mäßig",
"severe": "Schwer"
}
},
"moreActions": "Weitere Aktionen"
},
"patientForm": {
"editTitle": "Datensatz bearbeiten",
@@ -2089,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",
+18 -3
View File
@@ -364,8 +364,10 @@
"failedTitle": "Couldn't delete patient",
"failedBody": "Please try again."
},
"filterLabel": "Filter",
"filterStatus": "Filter by status",
"allStatuses": "All statuses"
"allStatuses": "All statuses",
"moreActions": "More actions"
},
"appointments": {
"title": "Appointments & Schedule",
@@ -1132,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",
@@ -1535,7 +1542,8 @@
"mild": "Mild",
"moderate": "Moderate",
"severe": "Severe"
}
},
"moreActions": "More actions"
},
"patientForm": {
"editTitle": "Edit record",
@@ -2087,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",
+18 -3
View File
@@ -364,8 +364,10 @@
"failedTitle": "Impossible de supprimer le patient",
"failedBody": "Veuillez réessayer."
},
"filterLabel": "Filtre",
"filterStatus": "Filtrer par statut",
"allStatuses": "Tous les statuts"
"allStatuses": "Tous les statuts",
"moreActions": "Plus dactions"
},
"appointments": {
"title": "Rendez-vous & Planning",
@@ -1132,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",
@@ -1535,7 +1542,8 @@
"mild": "Légère",
"moderate": "Modérée",
"severe": "Sévère"
}
},
"moreActions": "Plus dactions"
},
"patientForm": {
"editTitle": "Modifier le dossier",
@@ -2089,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",
+18 -3
View File
@@ -364,8 +364,10 @@
"failedTitle": "Bukaanka lama tirtiri karin",
"failedBody": "Fadlan isku day mar kale."
},
"filterLabel": "Kala sooc",
"filterStatus": "Ku kala sooc xaaladda",
"allStatuses": "Dhammaan xaaladaha"
"allStatuses": "Dhammaan xaaladaha",
"moreActions": "Ficillo dheeraad ah"
},
"appointments": {
"title": "Ballamaha & Jadwalka",
@@ -1132,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",
@@ -1535,7 +1542,8 @@
"mild": "Fudud",
"moderate": "Dhexdhexaad",
"severe": "Daran"
}
},
"moreActions": "Ficillo dheeraad ah"
},
"patientForm": {
"editTitle": "Wax ka beddel diiwaanka",
@@ -2089,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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.13.0",
"version": "0.14.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.13.0",
"version": "0.14.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"