Compare commits

...

11 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
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
Khalid Abdi 7846dacd42 chore: release v0.13.0
Bump version to 0.13.0 across root/backend/frontend, add the 0.13.0 CHANGELOG
entry, and refresh stale CLAUDE.md docs (the AI chat is real and @ai-sdk/react
is installed; the signing/approval flow is built).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:17:55 +03:00
Khalid Abdi 2c239fbd27 frontend: UI fixes + settings/patients features
- Record history: replace fragile COSS Timeline separator math with a
  continuous-rail list so every audited change renders in full (RTL-safe).
- Prescriptions: new reusable COSS DatePicker (Popover + Calendar) replaces
  raw <input type="date"> for Start/End date.
- Update banner: rebuilt with COSS Alert variant="warning"; pinned physical
  bottom-right so it stays bottom-right under Arabic RTL.
- AI setup notice: thin attached warning banner with clearer copy; now shown
  above the input in active chats too, not just the empty state.
- Settings profile: wire the previously dead Specialty (Select) and
  Professional links (editable rows) into the persisted preferences.
- Patients: add a status filter and broaden search to conditions/allergies.
- Sidebar user menu: quick language switch submenu (RTL already wired).
- ai-elements: fix a subset of Base UI type drift (22 -> 12; remainder is
  cmdk->Autocomplete migration drift kept behind ignoreBuildErrors).
- i18n: new keys added across all five locales (en/de/fr/ar/so).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:06:38 +03:00
46 changed files with 1409 additions and 420 deletions
+111
View File
@@ -7,6 +7,117 @@ 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
- **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
- **Prescription date pickers.** The New Prescription dialog's Start/End date now use a proper COSS
date picker (`frontend/components/ui/date-picker.tsx`, Popover + Calendar) instead of the raw
native date input.
- **Working profile fields.** Settings → Profile **Specialty** (a Select) and **Professional links**
(editable rows) are now wired to persisted preferences — previously they were inert stubs.
- **Patient filters.** The Patients page gains a status filter (all / active / inpatient /
discharged) and its search now also matches conditions and allergies.
- **Language quick-switch.** A language submenu in the sidebar user menu (alongside Theme), applied
immediately with Arabic RTL.
- **Wallet app — home & settings.** The patient app home screen adds a quick-action row (Share
record / My wallet / Notifications) and a recent-activity feed; the settings screen adds an About
section (version, docs, blog) and a privacy footer.
### Changed
- **Record history renders in full.** The patient sheet's Record history replaces the fragile
timeline-separator layout with a continuous-rail list, so every audited change shows completely
(and mirrors correctly under RTL).
- **Update banner + AI setup notice.** The "update available" banner now uses the COSS warning Alert
and stays bottom-right under Arabic; the "connect an AI provider" notice is a clearer, thinner bar
shown above the chat input in active conversations too.
- **Wallet app — bottom sheets.** Polished the shared sheet building blocks (spacing, radii, close
and action affordances).
### Fixed
- **Blog reachable.** `blog.temetro.com` was returning 502 due to a custom-domain target-port
mismatch (Ghost listens on 2368); documented and corrected.
- **Docs freshness.** Corrected stale `CLAUDE.md` claims (the AI chat is real and `@ai-sdk/react` is
installed; the signing/approval flow is built) and reduced the vendored `ai-elements` Base UI
type-drift errors.
## [0.12.1] — 2026-07-10
### Changed
+5 -2
View File
@@ -31,9 +31,12 @@ repository (published as `temetro`).
> is imported (with optional **temporary share + auto-delete**). Clinic→wallet **record-update push**
> and **QR pairing** are built too. See `backend/src/routes/{signing,patients-wallet}.ts`.
>
> **Also built:** the **AI chat is real** — the frontend streams from the backend's tool-using
> agent (`POST /api/chat`), not mock replies (see `frontend/CLAUDE.md`).
>
> **Still vision, not built:** in-app record editing and cryptographic time-boxing of temporary
> shares. The AI chat is still **mock replies**. Email verification is wired but currently **not
> enforced** at sign-in (see `backend/CLAUDE.md`).
> shares. Email verification is wired but currently **not enforced** at sign-in (see
> `backend/CLAUDE.md`).
## Patient wallet app (sibling repo `~/Desktop/temetro-app`)
+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.12.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";
+8 -6
View File
@@ -12,8 +12,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`temetro` — an **open-source** clinical "AI middleman" (see the project vision in the root
`../CLAUDE.md`). This app is the clinician-facing chat UI. It is now **wired to the real
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo); the AI
chat replies are **still mocked** (no LLM call yet).
`../backend/`** for authentication and patient data (no longer a standalone UI-only demo). The AI
chat is **real**: `components/chat/chat-panel.tsx` streams from the backend's tool-using agent
(`POST /api/chat`) via `useChat` and renders the record data parts it streams back.
- The chat parses `/patient <file#>` (or a bare `/<file#>`) in `components/chat/chat-panel.tsx` and
renders the record as a horizontal row of cards (`components/chat/patient-cards.tsx`), with small
@@ -21,7 +22,7 @@ chat replies are **still mocked** (no LLM call yet).
dialog; the Summary card has an **Edit record** button.
- Patients can be **created and edited** via the shared `components/chat/patient-form-dialog.tsx`
(mode `create` | `edit`). The "Add patient" pill in `chat-input.tsx` opens it.
- Non-command messages still get a mock assistant reply.
- Non-command messages stream a real reply from the backend agent (`POST /api/chat`).
### Auth & data (talks to `../backend`)
@@ -42,7 +43,8 @@ chat replies are **still mocked** (no LLM call yet).
`components/settings/settings-care-team.tsx` manages members + invitations.
- `.env.local` / `.env.example` hold `NEXT_PUBLIC_API_URL`.
The signing / patient-owned-storage / approval features from the root vision are **still not built**.
The signing / patient-owned-storage / approval features from the root vision **are built** (clinic
signing key, encrypted share/import, patient approval, clinic→wallet update push, QR pairing).
## Commands
@@ -77,8 +79,8 @@ trailer. See the root `../CLAUDE.md` for the project-wide per-folder commit poli
`components.json` (baseColor `neutral`).
- **`components/ai-elements/`** — a large AI-chat primitive library (`PromptInput`, `Conversation`,
`Message`, `Suggestion`, etc.) typed against **AI SDK v6** (`ai` package: `UIMessage`,
`ChatStatus`, `FileUIPart`). Note: `@ai-sdk/react` (`useChat`) is **not installed** — chat state
is managed with local React state.
`ChatStatus`, `FileUIPart`). `@ai-sdk/react` (`useChat`) **is installed** and drives the live chat
(`chat-panel.tsx`) with a custom transport pointed at `POST /api/chat`.
- **`components/sidebar-02/`** — the dashboard sidebar (`SidebarProvider` / `Sidebar` /
`SidebarInset` from `components/ui/sidebar.tsx`). `app-sidebar.tsx` holds the nav config (New chat
· Patients · Settings) + notifications; `team-switcher.tsx` is the `OrgSwitcher` (clinic switch).
@@ -369,15 +369,18 @@ export const AttachmentRemove = ({
// AttachmentHoverCard - Hover preview
// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard> & {
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
// back-compat but no longer forwarded to the Root.
openDelay?: number;
closeDelay?: number;
};
export const AttachmentHoverCard = ({
openDelay = 0,
closeDelay = 0,
openDelay,
closeDelay,
...props
}: AttachmentHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
}: AttachmentHoverCardProps) => <HoverCard {...props} />;
export type AttachmentHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
+1 -1
View File
@@ -56,7 +56,7 @@ export const Context = ({
return (
<ContextContext.Provider value={contextValue}>
<HoverCard closeDelay={0} openDelay={0} {...props} />
<HoverCard {...props} />
</ContextContext.Provider>
);
};
@@ -50,7 +50,7 @@ export const InlineCitationText = ({
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
export const InlineCitationCard = (props: InlineCitationCardProps) => (
<HoverCard closeDelay={0} openDelay={0} {...props} />
<HoverCard {...props} />
);
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
+14 -1
View File
@@ -128,5 +128,18 @@ export const PlanFooter = (props: PlanFooterProps) => (
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
<CollapsibleTrigger render={<Button className={cn("size-8", className)} data-slot="plan-trigger" size="icon" variant="ghost" {...props} />}><ChevronsUpDownIcon className="size-4" /><span className="sr-only">Toggle plan</span></CollapsibleTrigger>
<CollapsibleTrigger
{...props}
render={
<Button
className={cn("size-8", className)}
data-slot="plan-trigger"
size="icon"
variant="ghost"
/>
}
>
<ChevronsUpDownIcon className="size-4" />
<span className="sr-only">Toggle plan</span>
</CollapsibleTrigger>
);
@@ -1311,15 +1311,18 @@ export const PromptInputSelectValue = ({
<SelectValue className={cn(className)} {...props} />
);
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard> & {
// Base UI moved hover delay to the Trigger (`delay`); kept here for API
// back-compat but no longer forwarded to the Root.
openDelay?: number;
closeDelay?: number;
};
export const PromptInputHoverCard = ({
openDelay = 0,
closeDelay = 0,
openDelay,
closeDelay,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
}: PromptInputHoverCardProps) => <HoverCard {...props} />;
export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
@@ -107,7 +107,9 @@ export const SchemaDisplayPath = ({
<span
className={cn("font-mono text-sm", className)}
// oxlint-disable-next-line eslint-plugin-react(no-danger)
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
dangerouslySetInnerHTML={{
__html: typeof children === "string" ? children : highlightedPath,
}}
{...props}
/>
);
@@ -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 }),
);
+51 -27
View File
@@ -1,6 +1,6 @@
"use client";
import { Sparkles, X } from "lucide-react";
import { TriangleAlert, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -8,12 +8,19 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { getAiConfig } from "@/lib/ai-settings";
// A single, dismissible heads-up shown above the chat input on a fresh chat when
// no AI provider is configured yet (no API key, and no local Ollama). It only
// renders on the empty state, so it naturally disappears once a message is sent.
// A thin, dismissible warning bar shown flush above the chat input whenever no
// AI provider is wired up yet — either no API key for any cloud provider, or the
// app is in local mode with no Ollama endpoint set. Without this, sending a
// 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(() => {
@@ -21,43 +28,60 @@ 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
className="flex w-full items-start gap-3 rounded-2xl border border-info/30 bg-info/8 px-4 py-3 text-sm dark:bg-info/12"
className="flex w-full items-center gap-2.5 rounded-2xl border border-warning/32 bg-warning/8 px-3.5 py-2 text-sm dark:bg-warning/12"
role="status"
>
<Sparkles className="mt-0.5 size-4 shrink-0 text-info-foreground" />
<div className="flex-1 space-y-0.5">
<p className="font-medium text-foreground">
{t("chat.setupNotice.title")}
</p>
<p className="text-muted-foreground">{t("chat.setupNotice.body")}</p>
<Button
className="mt-1 px-0 text-info-foreground"
render={<Link href="/settings?tab=ai" />}
size="sm"
variant="link"
>
{t("chat.setupNotice.action")}
</Button>
</div>
<TriangleAlert className="size-4 shrink-0 text-warning" />
<p className="min-w-0 flex-1 truncate text-foreground">
<span className="font-medium">
{isOff
? t("chat.setupNotice.offTitle")
: t("chat.setupNotice.title")}
</span>
<span className="text-muted-foreground max-sm:hidden">
{" — "}
{isOff ? t("chat.setupNotice.offBody") : t("chat.setupNotice.body")}
</span>
</p>
<Button
className="shrink-0"
render={<Link href="/settings?tab=ai" />}
size="sm"
variant="outline"
>
{t("chat.setupNotice.action")}
</Button>
<button
aria-label={t("chat.setupNotice.dismiss")}
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
+31 -3
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;
})}
@@ -723,8 +749,7 @@ export function ChatPanel() {
<div className="flex w-full flex-col gap-3">
{errorAlert}
{veilGate}
{/* One-time setup heads-up — only on the empty state, so it clears
itself once the first message is sent. */}
{/* Setup heads-up when no AI provider is configured. */}
<AiSetupNotice />
{promptInput}
<Suggestions className="justify-center pt-1">
@@ -761,6 +786,9 @@ export function ChatPanel() {
{errorAlert}
{veilGate}
{queuePanel}
{/* Also warn mid-conversation when no AI provider is configured, so
failing replies have a visible cause and a fix. */}
<AiSetupNotice />
{promptInput}
</div>
</div>
@@ -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;
}
@@ -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,
@@ -272,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 })
@@ -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 ? (
+91 -91
View File
@@ -7,6 +7,7 @@ import {
type LucideIcon,
ListTodo,
Mic,
MoreHorizontal,
Network,
NotebookPen,
Pencil,
@@ -21,16 +22,6 @@ import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { AttachmentsSection } from "@/components/patients/patient-files";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
} from "@/components/ui/timeline";
import {
type ActivityEntityType,
type ActivityEntry,
@@ -39,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,
@@ -182,36 +180,41 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
{t("patientCard.history.empty")}
</p>
) : (
// Every entry is a past, audited event, so mark them all completed
// (filled indicators) by seeding the active step past the last item.
<Timeline defaultValue={entries.length}>
// A plain vertical rail so EVERY audited change renders in full — the
// icon column draws a connector that stretches to the next entry, and
// the flex layout mirrors correctly under RTL.
<ol className="space-y-0">
{entries.map((e, i) => {
const Icon = historyIcon[e.entityType] ?? Pencil;
const isLast = i === entries.length - 1;
return (
<TimelineItem
className="group-data-[orientation=vertical]/timeline:ms-10"
key={e.id}
step={i + 1}
>
<TimelineHeader>
<TimelineSeparator className="group-data-[orientation=vertical]/timeline:-left-7 group-data-[orientation=vertical]/timeline:h-[calc(100%-1.5rem-0.25rem)] group-data-[orientation=vertical]/timeline:translate-y-6.5" />
<TimelineTitle className="mt-0.5">
{e.actorName}
</TimelineTitle>
<TimelineIndicator className="group-data-[orientation=vertical]/timeline:-left-7 flex size-6 items-center justify-center border-none bg-primary/10 text-primary group-data-completed/timeline-item:bg-primary group-data-completed/timeline-item:text-primary-foreground">
<li className="flex gap-3" key={e.id}>
<div className="flex flex-col items-center">
<span className="flex size-6 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon size={14} />
</TimelineIndicator>
</TimelineHeader>
<TimelineContent>
{e.action}
<TimelineDate className="mt-1 mb-0">
</span>
{!isLast && (
<span
aria-hidden="true"
className="my-1 w-px flex-1 bg-primary/15"
/>
)}
</div>
<div
className={`min-w-0 flex-1 ${isLast ? "" : "pb-5"}`}
>
<p className="font-medium text-foreground text-sm">
{e.actorName}
</p>
<p className="text-muted-foreground text-sm">{e.action}</p>
<time className="mt-0.5 block font-medium text-muted-foreground text-xs">
{new Date(e.createdAt).toLocaleString()}
</TimelineDate>
</TimelineContent>
</TimelineItem>
</time>
</div>
</li>
);
})}
</Timeline>
</ol>
)}
</Section>
);
@@ -300,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}
@@ -307,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 && (
@@ -320,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")}>
+154 -62
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,10 +11,40 @@ 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,
SelectPopup,
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"];
const STATUS_FILTERS: StatusFilter[] = [
"all",
"active",
"inpatient",
"discharged",
];
// Rows shown per page on the patients table before paginating.
const PAGE_SIZE = 10;
@@ -32,6 +62,7 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
export function PatientsView() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [addOpen, setAddOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form.
@@ -69,9 +100,18 @@ export function PatientsView() {
}, []);
const q = query.trim().toLowerCase();
const patients = allPatients.filter(
(p) => !q || p.name.toLowerCase().includes(q) || p.fileNumber.includes(q)
);
// Search matches name, MRN, problem/condition labels, and allergy substances;
// the status filter narrows to one clinical status.
const patients = allPatients.filter((p) => {
if (statusFilter !== "all" && p.status !== statusFilter) return false;
if (!q) return true;
return (
p.name.toLowerCase().includes(q) ||
p.fileNumber.includes(q) ||
p.problems.some((problem) => problem.label.toLowerCase().includes(q)) ||
p.allergies.some((a) => a.substance.toLowerCase().includes(q))
);
});
// Client-side pagination over the filtered list (10/page). Searching resets to
// the first page (done in the search handler); `page` is clamped at render so a
@@ -133,15 +173,6 @@ export function PatientsView() {
value={query}
/>
</div>
<Button
className="rounded-3xl"
onClick={() => setImportOpen(true)}
type="button"
variant="outline"
>
<Smartphone className="size-4" />
{t("patients.importFromApp")}
</Button>
<Button
className="rounded-3xl"
onClick={() => {
@@ -153,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) => {
@@ -216,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} />
@@ -226,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
@@ -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 {
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import {
Dialog,
DialogClose,
@@ -346,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() }),
);
@@ -578,19 +579,17 @@ export function AddPrescriptionDialog({
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={t("prescriptions.dialog.startDate")}>
<input
className={controlClass}
onChange={(event) => setStartDate(event.target.value)}
type="date"
<DatePicker
onChange={setStartDate}
placeholder={t("prescriptions.dialog.selectDate")}
value={startDate}
/>
</Field>
<Field label={t("prescriptions.dialog.endDate")}>
<input
className={controlClass}
<DatePicker
min={startDate || undefined}
onChange={(event) => setEndDate(event.target.value)}
type="date"
onChange={setEndDate}
placeholder={t("prescriptions.dialog.selectDate")}
value={endDate}
/>
</Field>
+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 ? (
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronDown, Plus } from "lucide-react";
import { Plus, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -26,6 +26,7 @@ import {
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import { SPECIALTIES, specialtyLabel } from "@/lib/staff";
import {
getSettings,
saveSettings,
@@ -60,8 +61,21 @@ const DEFAULT_PREFS: UserPreferences = {
"notif.recordsShared": true,
clinic: "",
contactEmail: "",
specialty: "",
// Professional links stored as a JSON array string (values are boolean|string).
links: "",
};
// Parse the stored `links` preference (a JSON array string) into an array.
function parseLinks(value: unknown): string[] {
try {
const parsed = JSON.parse(String(value || "[]"));
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch {
return [];
}
}
export function ProfilePanel() {
const { t, i18n } = useTranslation();
const { data: session } = authClient.useSession();
@@ -110,13 +124,23 @@ export function ProfilePanel() {
const setPref = (key: string, value: boolean | string) =>
setPrefs((prev) => ({ ...prev, [key]: value }));
const links = parseLinks(prefs.links);
const setLinks = (next: string[]) =>
setPref("links", JSON.stringify(next));
const dirty =
name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline);
const save = async () => {
setSaving(true);
try {
const saved = await saveSettings(prefs);
// Drop blank link rows before persisting.
const cleanedLinks = links.map((l) => l.trim()).filter(Boolean);
const toSave: UserPreferences = {
...prefs,
links: cleanedLinks.length ? JSON.stringify(cleanedLinks) : "",
};
const saved = await saveSettings(toSave);
const trimmed = name.trim();
if (trimmed && trimmed !== baselineName) {
const { error } = await authClient.updateUser({ name: trimmed });
@@ -182,13 +206,31 @@ export function ProfilePanel() {
<div className="space-y-1.5">
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
<Select
onValueChange={(value) => setPref("specialty", value ?? "")}
value={String(prefs.specialty ?? "")}
>
{t("settings.profile.selectSpecialty")}
<ChevronDown className="size-4" />
</button>
<SelectTrigger>
<SelectValue>
{(value: string) =>
value ? (
specialtyLabel(t, value)
) : (
<span className="text-muted-foreground">
{t("settings.profile.selectSpecialty")}
</span>
)
}
</SelectValue>
</SelectTrigger>
<SelectPopup>
{SPECIALTIES.map((s) => (
<SelectItem key={s} value={s}>
{t(`settings.careTeam.specialties.${s}`)}
</SelectItem>
))}
</SelectPopup>
</Select>
</div>
<div className="grid gap-4 sm:grid-cols-2">
@@ -221,7 +263,43 @@ export function ProfilePanel() {
{t("settings.profile.professionalLinksHint")}
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
{links.length > 0 ? (
<div className="space-y-2">
{links.map((link, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: rows are positional
<div className="flex items-center gap-2" key={index}>
<Input
inputMode="url"
onChange={(event) =>
setLinks(
links.map((value, i) =>
i === index ? event.target.value : value,
),
)
}
placeholder={t("settings.profile.linkPlaceholder")}
value={link}
/>
<Button
aria-label={t("settings.profile.removeLink")}
onClick={() =>
setLinks(links.filter((_, i) => i !== index))
}
size="icon-sm"
variant="ghost"
>
<X className="size-4" />
</Button>
</div>
))}
</div>
) : null}
<Button
className="rounded-lg"
onClick={() => setLinks([...links, ""])}
size="sm"
variant="outline"
>
<Plus className="size-4" />
{t("settings.profile.addLink")}
</Button>
@@ -297,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")}
+33 -1
View File
@@ -4,6 +4,7 @@ import {
Building2,
Check,
ChevronsUpDown,
Languages,
LogOut,
Moon,
Plus,
@@ -49,6 +50,8 @@ import {
useSidebar,
} from "@/components/ui/sidebar";
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import { useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
@@ -82,7 +85,13 @@ function GitHubIcon({ className }: { className?: string }) {
// shortcut hint (below Theme) and a clinic switcher submenu (below that) whose
// popup reveals the active clinic's details on hover.
export function NavUser() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const activeLang = i18n.resolvedLanguage ?? i18n.language;
const changeLanguage = (lng: string) => {
if (lng === activeLang) return;
void i18n.changeLanguage(lng);
void persistLanguage(lng);
};
const { isMobile, state } = useSidebar();
const isCollapsed = state === "collapsed";
const router = useRouter();
@@ -197,6 +206,29 @@ export function NavUser() {
</MenuShortcut>
</MenuItem>
{/* Language: quick switch (also available in Settings). Applies
immediately and roams via the backend preferences. */}
<MenuSub>
<MenuSubTrigger>
<Languages />
<span className="truncate">{t("userMenu.language")}</span>
</MenuSubTrigger>
<MenuSubPopup className="min-w-44" sideOffset={8}>
{supportedLanguages.map((lng) => (
<MenuItem
className="gap-2"
key={lng}
onClick={() => changeLanguage(lng)}
>
<span className="flex-1 truncate">
{t(`settings.profile.language.${lng}`)}
</span>
{lng === activeLang && <Check className="size-4" />}
</MenuItem>
))}
</MenuSubPopup>
</MenuSub>
{/* Command palette: hint + shortcut, sits below Theme. */}
<MenuItem onClick={openCommand}>
<Search />
+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,
+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 };
+103
View File
@@ -0,0 +1,103 @@
"use client";
import { CalendarIcon } from "lucide-react";
import type { Matcher } from "react-day-picker";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
// Parse a `YYYY-MM-DD` string as a LOCAL date. `new Date("YYYY-MM-DD")` parses
// as UTC and can shift the day across timezones, so build it from parts.
function parseISODate(value?: string): Date | undefined {
if (!value) return undefined;
const [y, m, d] = value.split("-").map(Number);
if (!y || !m || !d) return undefined;
return new Date(y, m - 1, d);
}
function toISODate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
export interface DatePickerProps {
/** Selected date as `YYYY-MM-DD` (matches native date-input semantics). */
value?: string;
onChange: (value: string) => void;
/** Earliest selectable date, `YYYY-MM-DD`. */
min?: string;
/** Latest selectable date, `YYYY-MM-DD`. */
max?: string;
placeholder?: string;
id?: string;
className?: string;
disabled?: boolean;
}
// A COSS date picker: an outline Button trigger opening a Popover with the COSS
// Calendar. Drop-in replacement for `<input type="date">`, keeping the same
// string value shape so callers don't change their state handling.
export function DatePicker({
value,
onChange,
min,
max,
placeholder,
id,
className,
disabled,
}: DatePickerProps): React.ReactElement {
const [open, setOpen] = useState(false);
const selected = parseISODate(value);
const minDate = parseISODate(min);
const maxDate = parseISODate(max);
const disabledMatchers: Matcher[] = [
...(minDate ? [{ before: minDate }] : []),
...(maxDate ? [{ after: maxDate }] : []),
];
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
render={
<Button
className={cn(
"w-full justify-between font-normal",
!selected && "text-muted-foreground",
className,
)}
disabled={disabled}
id={id}
variant="outline"
/>
}
>
{selected ? selected.toLocaleDateString() : (placeholder ?? "")}
<CalendarIcon className="opacity-80" />
</PopoverTrigger>
<PopoverPopup align="start" className="w-auto">
<Calendar
autoFocus
defaultMonth={selected ?? minDate}
disabled={disabledMatchers.length ? disabledMatchers : undefined}
mode="single"
onSelect={(date?: Date) => {
onChange(date ? toISODate(date) : "");
if (date) setOpen(false);
}}
selected={selected}
/>
</PopoverPopup>
</Popover>
);
}
+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}
/>
);
}
+31 -21
View File
@@ -1,10 +1,12 @@
"use client";
import Link from "next/link";
import { X } from "lucide-react";
import { ArrowUpCircle, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Alert, AlertAction, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { getVersionInfo } from "@/lib/version";
const DISMISS_KEY = "temetro:update-dismissed";
@@ -39,28 +41,36 @@ export function UpdateBanner() {
setLatest(null);
};
// Pinned to the physical bottom-right in both LTR and RTL. The user wants it
// in the bottom-right corner in Arabic too, so we use physical `right`/`bottom`
// rather than the logical `end` (which would flip to the left under RTL).
return (
<div className="fixed end-4 bottom-4 z-50 flex max-w-sm items-start gap-3 rounded-2xl border border-border bg-card px-4 py-3 shadow-lg">
<div className="space-y-1.5">
<p className="text-sm text-foreground">
<div className="fixed right-4 bottom-4 z-50 w-full max-w-sm" dir="auto">
<Alert className="bg-card shadow-lg" variant="warning">
<ArrowUpCircle />
<AlertTitle className="text-foreground">
{t("settings.version.banner", { version: latest })}
</p>
<Link
className="text-sm font-medium text-primary underline-offset-4 hover:underline"
href="/settings?tab=version"
onClick={dismiss}
>
{t("settings.version.bannerUpdate")}
</Link>
</div>
<button
aria-label={t("settings.version.bannerDismiss")}
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={dismiss}
type="button"
>
<X className="size-4" />
</button>
</AlertTitle>
<AlertAction>
<Button
render={
<Link href="/settings?tab=version" onClick={dismiss} />
}
size="sm"
variant="outline"
>
{t("settings.version.bannerUpdate")}
</Button>
<Button
aria-label={t("settings.version.bannerDismiss")}
onClick={dismiss}
size="icon-sm"
variant="ghost"
>
<X className="size-4" />
</Button>
</AlertAction>
</Alert>
</div>
);
}
+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";
+29 -7
View File
@@ -263,7 +263,8 @@
"createDialogDescription": "أضف عيادة جديدة وانتقل إليها.",
"signOutFailed": "فشل تسجيل الخروج",
"tryAgain": "يرجى المحاولة مرة أخرى.",
"signedOut": "تم تسجيل الخروج"
"signedOut": "تم تسجيل الخروج",
"language": "اللغة"
},
"clinic": {
"create": "إنشاء عيادة",
@@ -374,7 +375,11 @@
"doneTitle": "تم حذف المريض",
"failedTitle": "تعذّر حذف المريض",
"failedBody": "يرجى المحاولة مرة أخرى."
}
},
"filterLabel": "تصفية",
"filterStatus": "تصفية حسب الحالة",
"allStatuses": "كل الحالات",
"moreActions": "إجراءات إضافية"
},
"appointments": {
"title": "المواعيد والجدول",
@@ -615,7 +620,8 @@
"removeDates": "إزالة",
"startDate": "تاريخ البدء",
"endDate": "تاريخ الانتهاء",
"addDates": "إضافة تاريخ البدء/الانتهاء"
"addDates": "إضافة تاريخ البدء/الانتهاء",
"selectDate": "اختر التاريخ"
}
},
"pharmacy": {
@@ -1144,7 +1150,12 @@
"title": "اربط نموذج ذكاء اصطناعي للبدء",
"body": "لم يتم إعداد أي مزوّد ذكاء اصطناعي بعد. أضف مفتاح API أو وجّه temetro إلى نموذج Ollama محلي حتى يتمكّن المساعد من الرد.",
"action": "فتح إعدادات الذكاء الاصطناعي",
"dismiss": "تجاهل"
"dismiss": "تجاهل",
"offTitle": "تم إيقاف مساعد الذكاء الاصطناعي",
"offBody": "الذكاء الاصطناعي مُعطّل في الإعدادات ← الذكاء الاصطناعي. فعّله لاستخدام المساعد."
},
"card": {
"unsupported": "بطاقة غير مدعومة"
},
"input": {
"placeholder": "اسأل أي شيء، أو اكتب /patient 10293",
@@ -1551,7 +1562,8 @@
"mild": "خفيف",
"moderate": "متوسط",
"severe": "شديد"
}
},
"moreActions": "إجراءات إضافية"
},
"patientForm": {
"editTitle": "تعديل السجل",
@@ -1886,7 +1898,9 @@
"pendingApprovalsDesc": "أبلغني عند موافقة مريض على تغيير معلّق أو رفضه",
"recordsShared": "سجلات شُوركت معي",
"recordsSharedDesc": "أبلغني عند مشاركة مريض سجلًا معي"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "إزالة الرابط"
},
"careTeam": {
"title": "فريق الرعاية",
@@ -2103,6 +2117,13 @@
"modeLocal": "نموذج محلي (Ollama)",
"modeApiHint": "تذهب الطلبات إلى مزوّدك المختار. تُزال هوية معرّفات المريض بواسطة Veil قبل مغادرتها.",
"modeLocalHint": "تعمل الطلبات مقابل نموذج على بنيتك التحتية الخاصة. لا تغادر بيانات المريض العيادة.",
"modeAuto": "تلقائي",
"modeOff": "إيقاف",
"modeAutoHint": "يستخدم مفتاح واجهة برمجة تطبيقات سحابي عند توفره، وإلا يعود إلى نموذج Ollama المحلي.",
"modeOffHint": "مساعد الذكاء الاصطناعي مُعطّل. لا تُرسل أي طلبات.",
"offTitle": "المساعد مُوقف",
"offDescription": "مساعد الذكاء الاصطناعي مُوقف لحسابك.",
"offNote": "اختر تلقائي أو مفتاح واجهة برمجة سحابي أو نموذج محلي أعلاه لتفعيل المساعد.",
"providerTitle": "المزوّد ومفتاح API",
"providerDescription": "يُشفَّر مفتاحك عند التخزين ولا يُعرض مرة أخرى أبدًا. يمكنك تخزين مفتاح لكل مزوّد والتبديل بينها.",
"provider": "المزوّد",
@@ -2236,6 +2257,7 @@
"prescription": "وصفة جديدة: {{drug}}",
"demographics": "تم تحديث البيانات الديموغرافية",
"note": "ملاحظة سريرية جديدة"
}
},
"summaryFallback": "تم تحديث السجل"
}
}
+29 -7
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Fügen Sie eine neue Klinik hinzu und wechseln Sie zu ihr.",
"signOutFailed": "Abmeldung fehlgeschlagen",
"tryAgain": "Bitte versuchen Sie es erneut.",
"signedOut": "Abgemeldet"
"signedOut": "Abgemeldet",
"language": "Sprache"
},
"clinic": {
"create": "Klinik erstellen",
@@ -362,7 +363,11 @@
"doneTitle": "Patient gelöscht",
"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"
},
"appointments": {
"title": "Termine & Zeitplan",
@@ -599,7 +604,8 @@
"removeDates": "Entfernen",
"startDate": "Startdatum",
"endDate": "Enddatum",
"addDates": "Start-/Enddatum hinzufügen"
"addDates": "Start-/Enddatum hinzufügen",
"selectDate": "Datum wählen"
}
},
"pharmacy": {
@@ -1128,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",
@@ -1531,7 +1542,8 @@
"mild": "Leicht",
"moderate": "Mäßig",
"severe": "Schwer"
}
},
"moreActions": "Weitere Aktionen"
},
"patientForm": {
"editTitle": "Datensatz bearbeiten",
@@ -1866,7 +1878,9 @@
"pendingApprovalsDesc": "Benachrichtigen Sie mich, wenn ein Patient eine ausstehende Änderung genehmigt oder ablehnt",
"recordsShared": "Mit mir geteilte Datensätze",
"recordsSharedDesc": "Benachrichtigen Sie mich, wenn ein Patient einen Datensatz mit mir teilt"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Link entfernen"
},
"careTeam": {
"title": "Behandlungsteam",
@@ -2083,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",
@@ -2216,6 +2237,7 @@
"prescription": "Neues Rezept: {{drug}}",
"demographics": "Stammdaten aktualisiert",
"note": "Neue klinische Notiz"
}
},
"summaryFallback": "Datensatz aktualisiert"
}
}
+29 -7
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Add a new clinic and switch to it.",
"signOutFailed": "Sign out failed",
"tryAgain": "Please try again.",
"signedOut": "Signed out"
"signedOut": "Signed out",
"language": "Language"
},
"clinic": {
"create": "Create clinic",
@@ -362,7 +363,11 @@
"doneTitle": "Patient deleted",
"failedTitle": "Couldn't delete patient",
"failedBody": "Please try again."
}
},
"filterLabel": "Filter",
"filterStatus": "Filter by status",
"allStatuses": "All statuses",
"moreActions": "More actions"
},
"appointments": {
"title": "Appointments & Schedule",
@@ -599,7 +604,8 @@
"removeDates": "Remove",
"startDate": "Start date",
"endDate": "End date",
"addDates": "Add start/end dates"
"addDates": "Add start/end dates",
"selectDate": "Select date"
}
},
"pharmacy": {
@@ -1128,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",
@@ -1531,7 +1542,8 @@
"mild": "Mild",
"moderate": "Moderate",
"severe": "Severe"
}
},
"moreActions": "More actions"
},
"patientForm": {
"editTitle": "Edit record",
@@ -1866,7 +1878,9 @@
"pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change",
"recordsShared": "Records shared with me",
"recordsSharedDesc": "Notify me when a patient shares a record with me"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Remove link"
},
"careTeam": {
"title": "Care team",
@@ -2081,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",
@@ -2216,6 +2237,7 @@
"prescription": "New prescription: {{drug}}",
"demographics": "Demographics updated",
"note": "New clinical note"
}
},
"summaryFallback": "Record updated"
}
}
+29 -7
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Ajoutez une nouvelle clinique et passez-y.",
"signOutFailed": "Échec de la déconnexion",
"tryAgain": "Veuillez réessayer.",
"signedOut": "Déconnecté"
"signedOut": "Déconnecté",
"language": "Langue"
},
"clinic": {
"create": "Créer une clinique",
@@ -362,7 +363,11 @@
"doneTitle": "Patient supprimé",
"failedTitle": "Impossible de supprimer le patient",
"failedBody": "Veuillez réessayer."
}
},
"filterLabel": "Filtre",
"filterStatus": "Filtrer par statut",
"allStatuses": "Tous les statuts",
"moreActions": "Plus dactions"
},
"appointments": {
"title": "Rendez-vous & Planning",
@@ -599,7 +604,8 @@
"removeDates": "Retirer",
"startDate": "Date de début",
"endDate": "Date de fin",
"addDates": "Ajouter les dates de début/fin"
"addDates": "Ajouter les dates de début/fin",
"selectDate": "Choisir une date"
}
},
"pharmacy": {
@@ -1128,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",
@@ -1531,7 +1542,8 @@
"mild": "Légère",
"moderate": "Modérée",
"severe": "Sévère"
}
},
"moreActions": "Plus dactions"
},
"patientForm": {
"editTitle": "Modifier le dossier",
@@ -1866,7 +1878,9 @@
"pendingApprovalsDesc": "Me notifier lorsqu'un patient approuve ou rejette une modification en attente",
"recordsShared": "Dossiers partagés avec moi",
"recordsSharedDesc": "Me notifier lorsqu'un patient partage un dossier avec moi"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Supprimer le lien"
},
"careTeam": {
"title": "Équipe soignante",
@@ -2083,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",
@@ -2216,6 +2237,7 @@
"prescription": "Nouvelle ordonnance : {{drug}}",
"demographics": "Données démographiques mises à jour",
"note": "Nouvelle note clinique"
}
},
"summaryFallback": "Dossier mis à jour"
}
}
+29 -7
View File
@@ -259,7 +259,8 @@
"createDialogDescription": "Ku dar rug cusub oo u beddel.",
"signOutFailed": "Ka-bixitaanka waa fashilmay",
"tryAgain": "Fadlan isku day mar kale.",
"signedOut": "Waa laga baxay"
"signedOut": "Waa laga baxay",
"language": "Luqadda"
},
"clinic": {
"create": "Samee rug",
@@ -362,7 +363,11 @@
"doneTitle": "Bukaanka waa la tirtiray",
"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"
},
"appointments": {
"title": "Ballamaha & Jadwalka",
@@ -599,7 +604,8 @@
"removeDates": "Ka saar",
"startDate": "Taariikhda bilowga",
"endDate": "Taariikhda dhammaadka",
"addDates": "Ku dar taariikhaha bilow/dhammaad"
"addDates": "Ku dar taariikhaha bilow/dhammaad",
"selectDate": "Dooro taariikhda"
}
},
"pharmacy": {
@@ -1128,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",
@@ -1531,7 +1542,8 @@
"mild": "Fudud",
"moderate": "Dhexdhexaad",
"severe": "Daran"
}
},
"moreActions": "Ficillo dheeraad ah"
},
"patientForm": {
"editTitle": "Wax ka beddel diiwaanka",
@@ -1866,7 +1878,9 @@
"pendingApprovalsDesc": "I ogeysii marka bukaan ansixiyo ama diido isbeddel la sugayo",
"recordsShared": "Diiwaanno la ila wadaagay",
"recordsSharedDesc": "I ogeysii marka bukaan diiwaan ila wadaago"
}
},
"linkPlaceholder": "https://example.com",
"removeLink": "Ka saar linkiga"
},
"careTeam": {
"title": "Kooxda daryeelka",
@@ -2083,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",
@@ -2216,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.12.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.12.1",
"version": "0.14.2",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"