Compare commits

...

3 Commits

Author SHA1 Message Date
Khalid Abdi bb536ba6da feat: clinic→wallet record-update push
A clinician can push an updated record to a wallet-linked patient (permanent
share). The snapshot is signed with the clinic Ed25519 key and sealed to the
wallet's X25519 key — derived from its Ed25519 wallet number via the birational
map, verified byte-for-byte against the wallet's own derivation. Stored pending,
delivered over the /wallet relay live and on the wallet's next authenticated
connect (offline catch-up). The patient approves/denies in-app; the wallet signs
its decision, the backend verifies it, and the record is replaced only on
approval. Wallet pins the clinic key (TOFU) and warns on change.

Backend: walletRecordUpdates table + service, ed25519PubToX25519Hex helper,
POST /api/patients/wallet/push, GET .../link/:fileNumber|updates|updates/:id,
wallet:update-request / wallet:update-response relay events.
Frontend: "Push to wallet" dialog with live status, wallet-link gating on the
patient sheet, "Sent updates" list under Settings → Signing, walletPush /
walletUpdatesList locale namespaces across all five languages. Bumps to v0.5.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:57:38 +03:00
Khalid Abdi b29fdff1cb feat: ambient AI visit scribe (record/paste → reviewed SOAP note)
Record a clinician↔patient visit (or paste a transcript) on the patient
sheet; the backend transcribes it (OpenAI Whisper / Gemini), de-identifies
the transcript + context through Veil, and drafts a structured SOAP note
the clinician reviews and edits before saving — the same write-approval
gate as the chat agent.

Backend: POST /api/scribe/{transcribe,draft,save} (routes/scribe.ts,
services/ai/transcribe.ts), veil.redactText() free-text redactor,
appendEncounter service, audio MIME types on attachments. Gated by
patient:write + the clinic AI policy (reception/disabled-AI excluded).
Frontend: ScribeDialog + lib/scribe.ts, "Record visit" on the patient
detail, gated by clinical access + AI availability. New `scribe` locale
namespace across all five languages. Bumps to v0.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:33:30 +03:00
Khalid Abdi d237504af9 frontend: add Somali, Arabic (RTL) & German languages
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside
en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support:

- config.ts registers the locales and exports a `dirFor` helper; an inline
  <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL
  flash), and i18n-provider keeps them in sync on language change.
- ~160 physical direction utilities converted to logical (ms/me/ps/pe/
  start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180;
  chat-bubble align variants fixed to logical.
- IBM Plex Sans Arabic appended to the sans/heading font stacks for
  per-character Arabic fallback.
- Language persists to the backend user_settings and re-applies on sign-in so it
  roams across devices (localStorage stays the offline source of truth).
- New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder
  parity and Arabic CLDR plural completeness.

Bump to 0.3.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 23:03:13 +03:00
90 changed files with 13277 additions and 126 deletions
+53
View File
@@ -7,6 +7,59 @@ for how releases are cut and published.
## [Unreleased]
## [0.5.0] — 2026-07-03
### Added
- **Clinic → wallet record-update push** — a clinician can push an updated record to a
**wallet-linked** patient (a permanent, approved share). The record snapshot is **signed**
with the clinic's Ed25519 key and **sealed** to the wallet's X25519 key (derived from its
Ed25519 wallet number via the birational map — verified byte-for-byte against the wallet's
own derivation), stored `pending`, and delivered over the `/wallet` relay live **and** on
the wallet's next authenticated connect (so an offline phone catches up). The patient
reviews it in a **pending-updates inbox** and approves/denies; the wallet signs its
decision, the backend verifies it, and the on-device record is replaced only on approval.
The wallet **pins** the clinic key (TOFU) and warns on a key change. New
`POST /api/patients/wallet/push`, `GET /api/patients/wallet/{link/:fileNumber,updates,updates/:id}`,
`walletRecordUpdates` table + service, `wallet:update-request` / `wallet:update-response`
relay events, a "Push to wallet" dialog with live status, and a "Sent updates" list under
Settings → Signing. New `walletPush` / `walletUpdatesList` locale namespaces across all five
languages. (The wallet app half ships in the sibling `temetro-app` repo.)
## [0.4.0] — 2026-07-03
### Added
- **Ambient AI visit scribe** — a **Record visit** action on the patient sheet turns a
clinician↔patient conversation into a draft **SOAP** encounter note. Record with the
microphone (`MediaRecorder`, stored as an auditable patient attachment) or paste a
transcript; the backend transcribes via the user's **OpenAI (Whisper)** or **Gemini**
key, de-identifies the transcript + patient context through **Veil**, and the model
drafts a structured note that the clinician **reviews and edits before saving** — the
same write-approval gate as the chat agent. New `POST /api/scribe/{transcribe,draft,save}`
(`backend/src/routes/scribe.ts`, `services/ai/transcribe.ts`), a `veil.redactText()`
free-text redactor, and an `appendEncounter` service that adds one note without touching
the rest of the record. Gated by `patient:write` + the clinic AI policy (reception and
disabled-AI accounts don't see it). New `scribe` locale namespace across all five
languages. Drafting also works with local Ollama from a pasted transcript.
## [0.3.0] — 2026-07-02
### Added
- **Three new interface languages** — Somali (`so`), Arabic (`ar`) and German
(`de`) join English and French, selectable in Settings → Profile → Language.
Each locale carries a full translation of the ~1,660 UI strings, with native
names shown in the selector (Soomaali, العربية, Deutsch).
- **Right-to-left (RTL) support** — selecting Arabic sets `dir="rtl"` on the
document (applied before first paint via an inline script, so no flash), flips
physical spacing/alignment to logical CSS utilities, mirrors directional icons,
and loads an Arabic-capable typeface (IBM Plex Sans Arabic) appended to the
font stack for per-character fallback.
- **Language roams across devices** — the chosen language is persisted to the
per-user `user_settings` preferences and re-applied on sign-in, with
localStorage remaining the offline source of truth.
- **`frontend/scripts/check-locales.mjs`** (+ `npm run check-locales`) — a parity
check that fails on missing/extra keys or `{{placeholder}}` mismatches across
locales and warns when Arabic count-keys lack the full CLDR plural forms.
## [0.2.5] — 2026-07-01
### Fixed
+21
View File
@@ -0,0 +1,21 @@
CREATE TABLE "wallet_record_updates" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" text NOT NULL,
"created_by" text NOT NULL,
"file_number" text NOT NULL,
"wallet_number" text NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"payload_sealed" text NOT NULL,
"clinic_signature" text NOT NULL,
"clinic_public_key" text NOT NULL,
"clinic_fingerprint" text NOT NULL,
"changes" jsonb DEFAULT '[]'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"delivered_at" timestamp,
"resolved_at" timestamp
);
--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wallet_record_updates" ADD CONSTRAINT "wallet_record_updates_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "wallet_updates_org_idx" ON "wallet_record_updates" USING btree ("organization_id");--> statement-breakpoint
CREATE INDEX "wallet_updates_wallet_idx" ON "wallet_record_updates" USING btree ("wallet_number");
File diff suppressed because it is too large Load Diff
+7
View File
@@ -211,6 +211,13 @@
"when": 1782057030557,
"tag": "0029_tiny_starhawk",
"breakpoints": true
},
{
"idx": 30,
"version": "7",
"when": 1783093188246,
"tag": "0030_medical_blur",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro-backend",
"version": "0.2.5",
"version": "0.5.0",
"private": true,
"type": "module",
"description": "temetro backend — Express + Postgres API with Better Auth (email/password, organizations) and org-scoped patient records.",
+1
View File
@@ -21,3 +21,4 @@ export * from "./staff-profile.js";
export * from "./meetings.js";
export * from "./signing.js";
export * from "./wallet-share.js";
export * from "./wallet-updates.js";
+53
View File
@@ -0,0 +1,53 @@
import { index, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { organization, user } from "./auth.js";
export type WalletUpdateStatus =
| "pending"
| "delivered"
| "approved"
| "denied";
// One row per clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient they can push the updated record to the patient's app;
// it lands here as `pending`, is sealed to the wallet's (X25519-from-Ed25519)
// key and signed with the clinic's Ed25519 key. The relay delivers it live if
// the device is connected, and again on the wallet's next authenticated connect
// (so an offline phone still receives it). The patient reviews the change,
// verifies the clinic signature, and approves/denies in-app — only then is the
// on-device record replaced. The clinic polls `status` for delivery/approval.
export const walletRecordUpdates = pgTable(
"wallet_record_updates",
{
id: uuid("id").primaryKey().defaultRandom(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdBy: text("created_by")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
fileNumber: text("file_number").notNull(),
walletNumber: text("wallet_number").notNull(),
status: text("status")
.$type<WalletUpdateStatus>()
.notNull()
.default("pending"),
// base64 sealed box of the full updated patient snapshot (sealed to the
// wallet's derived X25519 key).
payloadSealed: text("payload_sealed").notNull(),
// The clinic's Ed25519 signature over the plaintext bundle bytes + its
// public key + fingerprint, so the wallet can verify provenance (TOFU pin).
clinicSignature: text("clinic_signature").notNull(),
clinicPublicKey: text("clinic_public_key").notNull(),
clinicFingerprint: text("clinic_fingerprint").notNull(),
// Human-readable summary of what changed (shown in the wallet inbox).
changes: jsonb("changes").$type<string[]>().notNull().default([]),
createdAt: timestamp("created_at").defaultNow().notNull(),
deliveredAt: timestamp("delivered_at"),
resolvedAt: timestamp("resolved_at"),
},
(t) => [
index("wallet_updates_org_idx").on(t.organizationId),
index("wallet_updates_wallet_idx").on(t.walletNumber),
],
);
+2
View File
@@ -28,6 +28,7 @@ import { patientsRouter } from "./routes/patients.js";
import { patientsWalletRouter } from "./routes/patients-wallet.js";
import { portalRouter } from "./routes/portal.js";
import { prescriptionsRouter } from "./routes/prescriptions.js";
import { scribeRouter } from "./routes/scribe.js";
import { settingsRouter } from "./routes/settings.js";
import { signingRouter } from "./routes/signing.js";
import { staffRouter } from "./routes/staff.js";
@@ -104,6 +105,7 @@ app.use("/api/notifications", notificationsRouter);
app.use("/api/settings", settingsRouter);
app.use("/api/ai", aiRouter);
app.use("/api/chat", chatRouter);
app.use("/api/scribe", scribeRouter);
app.use("/api/integrations", integrationsRouter);
app.use("/api/portal", portalRouter);
app.use("/api/auth-helpers", authHelpersRouter);
+49
View File
@@ -0,0 +1,49 @@
import { bytesToHex } from "@noble/hashes/utils.js";
// Convert an Ed25519 public key to the matching X25519 (Montgomery) public key,
// so the clinic can `seal()` a record update to a wallet that only publishes an
// Ed25519 identity (its wallet number). The patient wallet derives the matching
// X25519 *private* key from its Ed25519 seed (SHA-512 clamp) to `open()` it —
// this file MUST stay byte-for-byte compatible with the wallet app's
// src/lib/crypto.ts. @noble/curves does not export edwardsToMontgomery in the
// pinned version, so the birational map u = (1 + y) / (1 - y) mod p is done here
// with BigInt. Verified: edPubToMontU(A) === x25519.getPublicKey(edClamp(seed)).
const P = 2n ** 255n - 19n;
function modpow(base: bigint, exp: bigint, mod: bigint): bigint {
let result = 1n;
let b = base % mod;
let e = exp;
while (e > 0n) {
if (e & 1n) result = (result * b) % mod;
b = (b * b) % mod;
e >>= 1n;
}
return result;
}
// Modular inverse via Fermat's little theorem (p is prime).
function inv(a: bigint): bigint {
return modpow(((a % P) + P) % P, P - 2n, P);
}
// Ed25519 public key (compressed, little-endian y with the x-sign in the high
// bit) → X25519 u-coordinate, returned as 32-byte little-endian hex.
export function ed25519PubToX25519Hex(edPub: Uint8Array): string {
if (edPub.length !== 32) throw new Error("Ed25519 public key must be 32 bytes.");
const bytes = edPub.slice();
bytes[31] = (bytes[31] as number) & 0x7f; // clear the x sign bit
let y = 0n;
for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i] as number);
y %= P;
// u = (1 + y) / (1 - y) (mod p)
const u = ((1n + y) * inv((1n - y + P) % P)) % P;
const out = new Uint8Array(32);
let v = u;
for (let i = 0; i < 32; i++) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return bytesToHex(out);
}
+46
View File
@@ -11,6 +11,7 @@ import * as meetings from "./services/meetings.js";
import * as messaging from "./services/messaging.js";
import { createNotification } from "./services/notifications.js";
import * as walletShare from "./services/wallet-share.js";
import * as walletUpdates from "./services/wallet-updates.js";
import type { MessageAttachment } from "./types/messaging.js";
let io: Server | null = null;
@@ -317,12 +318,57 @@ export function initRealtime(httpServer: HttpServer): Server {
socket.data.walletNumber = walletNumber;
socket.join(walletRoom(walletNumber));
ack?.({ ok: true });
// Deliver any record updates the device missed while offline. Sent
// after the ack so the client is ready to receive them.
void walletUpdates
.pendingUpdatesForWallet(walletNumber)
.then(async (rows) => {
for (const row of rows) {
socket.emit("wallet:update-request", await walletUpdates.toEvent(row));
await walletUpdates.markDelivered(row.id);
}
})
.catch(() => {});
} catch {
ack?.({ ok: false });
}
},
);
// The patient approved/denied a clinic→wallet record update on their device.
// We verify the wallet's signature over the decision and resolve the row.
socket.on(
"wallet:update-response",
async (
payload: {
requestId?: string;
walletNumber?: string;
decision?: "approved" | "denied";
signature?: string;
},
ack?: Ack,
) => {
try {
if (
!socket.data.walletNumber ||
socket.data.walletNumber !== payload?.walletNumber
) {
ack?.({ ok: false });
return;
}
const view = await walletUpdates.applyUpdateResponse(
String(payload?.requestId ?? ""),
String(payload?.walletNumber ?? ""),
payload?.decision === "approved" ? "approved" : "denied",
payload?.signature,
);
ack?.({ ok: !!view });
} catch (err) {
ack?.({ ok: false, error: (err as Error).message });
}
},
);
// The patient approved/denied a share on their device; the sealed bundle (if
// approved) rides along and is decrypted + verified server-side.
socket.on(
+8
View File
@@ -41,6 +41,14 @@ const ALLOWED_MIME = new Set([
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
// Ambient visit-scribe recordings (stored as a patient attachment so they're
// auditable). Voice Opus/AAC stays well under the 15 MB cap for a long visit.
"audio/webm",
"audio/ogg",
"audio/mp4",
"audio/mpeg",
"audio/wav",
"audio/x-m4a",
]);
// Disk storage under UPLOAD_DIR/<orgId>/, keyed by a random id so original
+89
View File
@@ -20,6 +20,7 @@ import { recordActivity } from "../services/activity.js";
import * as patientService from "../services/patients.js";
import { awaitQuickTunnelUrl } from "../services/relay-url.js";
import * as walletShare from "../services/wallet-share.js";
import * as walletUpdates from "../services/wallet-updates.js";
export const patientsWalletRouter = Router();
@@ -138,6 +139,94 @@ patientsWalletRouter.get(
},
);
// --- Clinic → wallet record-update push ------------------------------------
// Whether a patient is linked to a wallet (drives the "Push update" button).
// Returns the wallet number when linked, 404 otherwise.
patientsWalletRouter.get(
"/link/:fileNumber",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const walletNumber = await walletUpdates.walletNumberForPatient(
req.organizationId!,
req.params.fileNumber as string,
);
if (!walletNumber) throw new HttpError(404, "Not wallet-linked.");
res.json({ walletNumber });
} catch (err) {
next(err);
}
},
);
const pushSchema = z.object({
fileNumber: z.string().trim().min(1),
changes: z.array(z.string().trim().min(1)).min(1).max(50),
});
// Push the current record snapshot to the linked wallet. Seals + signs it,
// stores it pending, and delivers live if the device is connected (it is also
// re-sent on the wallet's next connect). The patient must approve in-app.
patientsWalletRouter.post(
"/push",
requirePermission({ patient: ["write"] }),
async (req, res, next) => {
try {
const input = pushSchema.parse(req.body);
const row = await walletUpdates.createRecordUpdate(
req.organizationId!,
req.user!.id,
input.fileNumber,
input.changes,
);
const event = await walletUpdates.toEvent(row);
emitToWallet(row.walletNumber, "wallet:update-request", event);
await recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Pushed a record update to a patient wallet (#${row.fileNumber})`,
entityType: "patient",
entityId: row.fileNumber,
patientFileNumber: row.fileNumber,
});
res.status(201).json(walletUpdates.viewOf(row));
} catch (err) {
next(err);
}
},
);
// The clinic's recent update pushes (Signing panel + status polling).
patientsWalletRouter.get(
"/updates",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
res.json(await walletUpdates.listUpdates(req.organizationId!));
} catch (err) {
next(err);
}
},
);
patientsWalletRouter.get(
"/updates/:id",
requirePermission({ patient: ["read"] }),
async (req, res, next) => {
try {
const view = await walletUpdates.getUpdate(
req.organizationId!,
req.params.id as string,
);
if (!view) throw new HttpError(404, "Update not found.");
res.json(view);
} catch (err) {
next(err);
}
},
);
// Commit the (possibly clinician-edited) draft into a real patient record. The
// temporary-share metadata (origin + auto-delete deadline) is taken from the
// request server-side, so the clinic can't quietly keep a temporary record.
+240
View File
@@ -0,0 +1,240 @@
import { readFile } from "node:fs/promises";
import { generateText } from "ai";
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error.js";
import { isReceptionOnly, providerScope } from "../lib/role-scope.js";
import {
requireAuth,
requireOrg,
requirePermission,
} from "../middleware/auth.js";
import { recordActivity } from "../services/activity.js";
import { getAiSettings } from "../services/ai/config.js";
import { aiAllowedFor, getPolicy } from "../services/ai/policy.js";
import { resolveModel } from "../services/ai/provider.js";
import { transcribeAudio } from "../services/ai/transcribe.js";
import { createVeil } from "../services/ai/veil.js";
import { absolutePath, getAttachmentRow } from "../services/attachments.js";
import { appendEncounter, getPatient } from "../services/patients.js";
import type { Encounter } from "../types/patient.js";
export const scribeRouter = Router();
// The ambient scribe drafts and saves a clinical encounter note, so it needs
// full clinical write access — never reception (demographics only).
scribeRouter.use(
requireAuth,
requireOrg,
requirePermission({ patient: ["write"] }),
);
// Guard shared by every scribe route: the clinic AI kill-switch must allow this
// member, and reception (demographics-only) can never draft clinical notes.
async function ensureScribeAllowed(req: {
organizationId?: string;
memberRole?: string;
}): Promise<void> {
if (isReceptionOnly(req.memberRole)) {
throw new HttpError(403, "The visit scribe is not available for your role.");
}
const policy = await getPolicy(req.organizationId!);
if (!aiAllowedFor(policy, req.memberRole)) {
throw new HttpError(403, "The AI assistant is disabled for your account.");
}
}
const transcribeSchema = z.object({
attachmentId: z.string().trim().min(1),
});
// POST /api/scribe/transcribe — turn a stored audio attachment into a raw
// transcript via the user's speech provider (OpenAI/Gemini). The audio does NOT
// pass through Veil or the chat loop.
scribeRouter.post("/transcribe", async (req, res, next) => {
try {
await ensureScribeAllowed(req);
const { attachmentId } = transcribeSchema.parse(req.body);
const row = await getAttachmentRow(req.organizationId!, attachmentId);
if (!row) throw new HttpError(404, "Recording not found.");
if (!row.mimeType.startsWith("audio/")) {
throw new HttpError(400, "That attachment is not an audio recording.");
}
const settings = await getAiSettings(req.user!.id);
const buffer = await readFile(absolutePath(row.storagePath));
const { transcript, provider } = await transcribeAudio(settings, {
buffer,
mimeType: row.mimeType,
filename: row.filename,
});
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Transcribed a visit recording (${provider})`,
entityType: "patient",
patientFileNumber: row.fileNumber,
});
res.json({ transcript });
} catch (err) {
next(err);
}
});
const draftSchema = z.object({
fileNumber: z.string().trim().min(1),
transcript: z.string().trim().min(1).max(100_000),
visitType: z.string().trim().max(120).optional(),
date: z.string().trim().max(40).optional(),
});
// Strip ```json fences and parse; returns null if the text isn't JSON.
function parseDraftJson(text: string): { type?: string; summary?: string } | null {
const cleaned = text
.replace(/^\s*```(?:json)?/i, "")
.replace(/```\s*$/i, "")
.trim();
try {
const parsed = JSON.parse(cleaned);
return typeof parsed === "object" && parsed ? parsed : null;
} catch {
return null;
}
}
const DRAFT_SYSTEM = [
"You are a clinical scribe. From a visit transcript, write a concise, structured",
"encounter note in SOAP format (Subjective, Objective, Assessment, Plan).",
"Rules:",
"- Use ONLY what the transcript supports. Never invent vitals, doses, or findings.",
"- If a SOAP section has nothing to report, write \"Not discussed.\" under it.",
"- Identifiers may appear as tokens like [PATIENT_1] or [PROVIDER_1]; keep them",
" verbatim — do not guess real names.",
"Respond with a JSON object ONLY (no prose, no code fences):",
'{ "type": "<short visit type, e.g. Follow-up / New patient / Telehealth>",',
' "summary": "<the SOAP note as markdown with **Subjective** / **Objective** /',
' **Assessment** / **Plan** headings>" }',
].join("\n");
// POST /api/scribe/draft — draft an encounter note from a transcript. The
// transcript + patient context are Veil-redacted before any external call and
// the output is rehydrated. Nothing is written — the clinician reviews and
// approves via POST /save (the same write-approval gate as the chat agent).
scribeRouter.post("/draft", async (req, res, next) => {
try {
await ensureScribeAllowed(req);
const { fileNumber, transcript, visitType, date } = draftSchema.parse(
req.body,
);
const patient = await getPatient(
req.organizationId!,
fileNumber,
false,
providerScope(req.memberRole, req.user!.id),
);
if (!patient) throw new HttpError(404, "Patient not found.");
const settings = await getAiSettings(req.user!.id);
const resolved = resolveModel(settings, settings.defaultModel);
const veil = createVeil(settings.veilLevel, resolved.isExternal);
// Seed Veil's token maps with this patient's identifiers, then redact the
// free-text transcript against them before it leaves the clinic.
const redactedPatient = veil.redactPatient(patient);
const redactedTranscript = veil.redactText(transcript);
const context = [
`Patient: ${redactedPatient.name} (MRN ${redactedPatient.fileNumber}), ${patient.age}y ${patient.sex}.`,
patient.problems.length
? `Known problems: ${patient.problems.map((p) => p.label).join(", ")}.`
: "",
patient.medications.length
? `Current medications: ${patient.medications
.map((m) => `${m.name} ${m.dose}`)
.join(", ")}.`
: "",
visitType ? `Visit type hint: ${visitType}.` : "",
"",
"Transcript:",
redactedTranscript,
]
.filter(Boolean)
.join("\n");
const result = await generateText({
model: resolved.model,
system: DRAFT_SYSTEM,
prompt: context,
});
const parsed = parseDraftJson(result.text);
const summary = veil.rehydrate(
(parsed?.summary ?? result.text ?? "").trim(),
);
const type = (parsed?.type ?? visitType ?? "Visit").trim() || "Visit";
const draft: Encounter = {
date: date || new Date().toISOString().slice(0, 10),
type,
// The responsible clinician is the signed-in user, not the model's guess.
provider: req.user!.name ?? "",
summary,
};
res.json({
draft,
veil: {
active: veil.active,
level: veil.level,
classes: veil.usedClasses(),
provider: resolved.providerLabel,
},
});
} catch (err) {
next(err);
}
});
const saveSchema = z.object({
fileNumber: z.string().trim().min(1),
encounter: z.object({
date: z.string().trim().min(1),
type: z.string().trim().min(1),
provider: z.string().trim().default(""),
summary: z.string().trim().min(1),
}),
});
// POST /api/scribe/save — the approval step: append the reviewed encounter note
// to the patient record. Re-validates server-side and audits like any add.
scribeRouter.post("/save", async (req, res, next) => {
try {
await ensureScribeAllowed(req);
const { fileNumber, encounter } = saveSchema.parse(req.body);
const updated = await appendEncounter(
req.organizationId!,
fileNumber,
encounter,
);
if (!updated) throw new HttpError(404, "Patient not found.");
void recordActivity({
orgId: req.organizationId!,
actor: { id: req.user!.id, name: req.user!.name },
action: `Added a scribe visit note for ${updated.name}`,
entityType: "patient",
entityId: updated.fileNumber,
patientName: updated.name,
patientFileNumber: updated.fileNumber,
});
res.status(201).json(updated);
} catch (err) {
next(err);
}
});
+132
View File
@@ -0,0 +1,132 @@
import { HttpError } from "../../lib/http-error.js";
import type { userAiSettings } from "../../db/schema/ai.js";
import { getApiKey } from "./config.js";
type AiSettingsRow = typeof userAiSettings.$inferSelect;
export type AudioInput = {
buffer: Buffer;
mimeType: string;
filename: string;
};
// Which transcription backend a user's AI settings can reach. Anthropic has no
// speech-to-text API, so an Anthropic-only user must paste a transcript instead.
export type TranscribeProvider = "openai" | "gemini";
export function transcribeProviderFor(
settings: AiSettingsRow,
): TranscribeProvider | null {
if (getApiKey(settings, "openai")) return "openai";
if (getApiKey(settings, "gemini")) return "gemini";
return null;
}
const OPENAI_MODEL = "whisper-1";
const GEMINI_MODEL = "gemini-2.5-flash";
const TRANSCRIBE_PROMPT =
"Transcribe this clinical visit recording verbatim. Return only the spoken words as plain text, with no commentary, headings, or timestamps.";
async function transcribeWithOpenAI(
apiKey: string,
audio: AudioInput,
): Promise<string> {
const form = new FormData();
form.append(
"file",
new Blob([new Uint8Array(audio.buffer)], { type: audio.mimeType }),
audio.filename,
);
form.append("model", OPENAI_MODEL);
form.append("response_format", "json");
const res = await fetch("https://api.openai.com/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new HttpError(
502,
`Transcription failed (OpenAI ${res.status}). ${detail.slice(0, 300)}`,
);
}
const json = (await res.json()) as { text?: string };
return (json.text ?? "").trim();
}
async function transcribeWithGemini(
apiKey: string,
audio: AudioInput,
): Promise<string> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent?key=${encodeURIComponent(
apiKey,
)}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents: [
{
parts: [
{
inline_data: {
mime_type: audio.mimeType,
data: audio.buffer.toString("base64"),
},
},
{ text: TRANSCRIBE_PROMPT },
],
},
],
}),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new HttpError(
502,
`Transcription failed (Gemini ${res.status}). ${detail.slice(0, 300)}`,
);
}
const json = (await res.json()) as {
candidates?: { content?: { parts?: { text?: string }[] } }[];
};
const text =
json.candidates?.[0]?.content?.parts
?.map((p) => p.text ?? "")
.join("")
.trim() ?? "";
return text;
}
// Send an audio recording to the user's transcription provider and return the
// raw transcript. The audio never passes through the chat loop or Veil — Veil
// cannot redact speech, so the caller must warn the clinician that audio leaves
// the clinic when an external provider is used (only the DRAFTING step is
// Veil-protected). Throws a 400 when no speech-capable provider is configured.
export async function transcribeAudio(
settings: AiSettingsRow,
audio: AudioInput,
): Promise<{ transcript: string; provider: TranscribeProvider }> {
const provider = transcribeProviderFor(settings);
if (!provider) {
throw new HttpError(
400,
"Transcription needs an OpenAI or Gemini API key. Add one in Settings → AI, or paste the visit transcript instead.",
);
}
const apiKey = getApiKey(settings, provider)!;
const transcript =
provider === "openai"
? await transcribeWithOpenAI(apiKey, audio)
: await transcribeWithGemini(apiKey, audio);
if (!transcript) {
throw new HttpError(
502,
"The transcription came back empty — try again or paste the transcript.",
);
}
return { transcript, provider };
}
+25
View File
@@ -26,6 +26,15 @@ export type Veil = {
redactPatient: (patient: Patient) => Patient;
/** Map a possibly-tokenized file number from a tool call back to the real one. */
resolveFileNumber: (input: string) => string;
/**
* De-identify free text (e.g. a visit transcript) by swapping any KNOWN
* identifiers — the patient name, MRN and provider names already seen via
* redactPatient — for their tokens. Seed the token maps by calling
* redactPatient(patient) first. Note: this only catches identifiers we know
* about; free-text PHI spoken aloud (addresses, relatives' names) is not
* covered — see the ambient-scribe consent notice.
*/
redactText: (text: string) => string;
/** Swap any tokens in model output back to real identifiers. */
rehydrate: (text: string) => string;
/** Token classes actually emitted — for the audit log. */
@@ -81,6 +90,21 @@ export function createVeil(level: VeilLevel, active: boolean): Veil {
return mrnByToken.get(input.trim()) ?? input;
}
function redactText(text: string): string {
if (!isActive || fromToken.size === 0) return text;
let out = text;
// Longest real values first so a provider name containing the patient name
// (or similar overlap) is replaced whole before its substrings.
const byLength = [...fromToken.entries()]
.filter(([, real]) => real.trim().length > 0)
.sort((a, b) => b[1].length - a[1].length);
for (const [token, real] of byLength) {
const escaped = real.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
out = out.replace(new RegExp(escaped, "gi"), token);
}
return out;
}
function rehydrate(text: string): string {
if (!isActive || fromToken.size === 0) return text;
let out = text;
@@ -101,6 +125,7 @@ export function createVeil(level: VeilLevel, active: boolean): Veil {
level,
redactPatient,
resolveFileNumber,
redactText,
rehydrate,
usedClasses,
};
+40
View File
@@ -570,6 +570,46 @@ export async function appendLabs(
return getPatient(orgId, fileNumber);
}
// Append a single encounter (visit note) without touching the rest of the
// record — used by the ambient AI scribe, which drafts one note at a time and
// must not go through updatePatient's wholesale child replacement. Position
// continues after the current max so the new note sorts last.
export async function appendEncounter(
orgId: string,
fileNumber: string,
entry: Encounter,
): Promise<Patient | null> {
const inserted = await db.transaction(async (tx) => {
const [existing] = await tx
.select({ id: patients.id })
.from(patients)
.where(
and(
eq(patients.organizationId, orgId),
eq(patients.fileNumber, fileNumber),
),
);
if (!existing) return false;
const [pos] = await tx
.select({ max: sql<number>`coalesce(max(${encounters.position}), -1)` })
.from(encounters)
.where(eq(encounters.patientId, existing.id));
await tx.insert(encounters).values({
patientId: existing.id,
position: (pos?.max ?? -1) + 1,
...entry,
});
await tx
.update(patients)
.set({ updatedAt: new Date() })
.where(eq(patients.id, existing.id));
return true;
});
if (!inserted) return null;
return getPatient(orgId, fileNumber);
}
// Remove a single lab result from a patient, identified by its
// name/value/takenAt (the frontend has no row id). Scoped to the org via the
// owning patient. Returns the reloaded patient, or null when the chart is gone.
+234
View File
@@ -0,0 +1,234 @@
import { hexToBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
import { db } from "../db/index.js";
import { organization } from "../db/schema/auth.js";
import { walletRecordUpdates } from "../db/schema/wallet-updates.js";
import { walletShareRequests } from "../db/schema/wallet-share.js";
import { HttpError } from "../lib/http-error.js";
import {
decodeWalletNumber,
fingerprint,
seal,
verifySignature,
} from "../lib/wallet-crypto.js";
import { ed25519PubToX25519Hex } from "../lib/wallet-x25519.js";
import { getPatient } from "./patients.js";
import { signWithClinicKey } from "./signing.js";
type UpdateRow = typeof walletRecordUpdates.$inferSelect;
// The payload the relay pushes to a wallet. `sealed` is the encrypted patient
// snapshot; `signature`/`clinicPublicKey`/`fingerprint` let the wallet verify
// provenance (TOFU pin) before applying.
export type WalletUpdateEvent = {
requestId: string;
clinicName: string;
sealed: string;
signature: string;
clinicPublicKey: string;
fingerprint: string;
changes: string[];
createdAt: string;
};
// The clinic-facing view (no ciphertext) for the "Sent updates" list + polling.
export type WalletUpdateView = {
id: string;
fileNumber: string;
walletNumber: string;
status: UpdateRow["status"];
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
export function viewOf(row: UpdateRow): WalletUpdateView {
return toView(row);
}
function toView(row: UpdateRow): WalletUpdateView {
return {
id: row.id,
fileNumber: row.fileNumber,
walletNumber: row.walletNumber,
status: row.status,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
deliveredAt: row.deliveredAt ? row.deliveredAt.toISOString() : null,
resolvedAt: row.resolvedAt ? row.resolvedAt.toISOString() : null,
};
}
// The wallet number a patient's record is linked to, or null when it isn't
// wallet-backed. Only *permanent, approved, committed* shares qualify —
// temporary shares auto-delete, so pushing an update to them is meaningless.
export async function walletNumberForPatient(
orgId: string,
fileNumber: string,
): Promise<string | null> {
const [row] = await db
.select({ walletNumber: walletShareRequests.walletNumber })
.from(walletShareRequests)
.where(
and(
eq(walletShareRequests.organizationId, orgId),
eq(walletShareRequests.committedFileNumber, fileNumber),
eq(walletShareRequests.status, "approved"),
eq(walletShareRequests.shareMode, "permanent"),
isNotNull(walletShareRequests.walletNumber),
),
)
.limit(1);
return row?.walletNumber ?? null;
}
// Compose, seal and sign a record-update push, and store it as pending. Loads
// the current patient snapshot, seals it to the wallet's derived X25519 key, and
// signs the plaintext bundle with the clinic's Ed25519 key. Returns the row.
export async function createRecordUpdate(
orgId: string,
userId: string,
fileNumber: string,
changes: string[],
): Promise<UpdateRow> {
const walletNumber = await walletNumberForPatient(orgId, fileNumber);
if (!walletNumber) {
throw new HttpError(409, "This patient is not linked to a wallet.");
}
const patient = await getPatient(orgId, fileNumber);
if (!patient) throw new HttpError(404, "Patient not found.");
// The wallet opens this, verifies the signature over the same bytes, then
// replaces its on-device record with `patient`.
const bundle = utf8ToBytes(JSON.stringify({ patient, changes }));
const { signature, publicKey } = await signWithClinicKey(orgId, bundle);
const x25519Hex = ed25519PubToX25519Hex(decodeWalletNumber(walletNumber));
const sealed = seal(x25519Hex, bundle);
const [row] = await db
.insert(walletRecordUpdates)
.values({
organizationId: orgId,
createdBy: userId,
fileNumber,
walletNumber,
payloadSealed: sealed,
clinicSignature: signature,
clinicPublicKey: publicKey,
clinicFingerprint: fingerprint(hexToBytes(publicKey)),
changes,
})
.returning();
return row!;
}
// Build the wire event for a stored update row (joins the clinic name).
export async function toEvent(row: UpdateRow): Promise<WalletUpdateEvent> {
const [org] = await db
.select({ name: organization.name })
.from(organization)
.where(eq(organization.id, row.organizationId));
return {
requestId: row.id,
clinicName: org?.name ?? "A clinic",
sealed: row.payloadSealed,
signature: row.clinicSignature,
clinicPublicKey: row.clinicPublicKey,
fingerprint: row.clinicFingerprint,
changes: row.changes,
createdAt: row.createdAt.toISOString(),
};
}
// Every unresolved update for a wallet — re-sent on each authenticated connect
// so an offline device eventually receives what it missed.
export async function pendingUpdatesForWallet(
walletNumber: string,
): Promise<UpdateRow[]> {
return db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.walletNumber, walletNumber),
isNull(walletRecordUpdates.resolvedAt),
),
)
.orderBy(walletRecordUpdates.createdAt);
}
// Mark a pending update delivered (best-effort; only advances from pending).
export async function markDelivered(id: string): Promise<void> {
await db
.update(walletRecordUpdates)
.set({ status: "delivered", deliveredAt: new Date() })
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.status, "pending"),
),
);
}
// Apply the patient's decision relayed back from the wallet. Verifies the
// wallet's Ed25519 signature over `${decision}:${requestId}` (provenance) before
// resolving. Returns the resolved view, or null when unknown/already resolved.
export async function applyUpdateResponse(
requestId: string,
walletNumber: string,
decision: "approved" | "denied",
signatureHex?: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.id, requestId));
if (!row || row.resolvedAt) return null;
if (row.walletNumber !== walletNumber.trim()) return null;
if (!signatureHex) return null;
const publicKey = decodeWalletNumber(walletNumber);
const message = utf8ToBytes(`${decision}:${requestId}`);
if (!verifySignature(publicKey, signatureHex, message)) {
throw new HttpError(400, "Response signature did not match the wallet.");
}
const [updated] = await db
.update(walletRecordUpdates)
.set({ status: decision, resolvedAt: new Date() })
.where(eq(walletRecordUpdates.id, requestId))
.returning();
return updated ? toView(updated) : null;
}
// Recent update pushes for the clinic (Signing panel "Sent updates" list).
export async function listUpdates(
orgId: string,
limit = 30,
): Promise<WalletUpdateView[]> {
const rows = await db
.select()
.from(walletRecordUpdates)
.where(eq(walletRecordUpdates.organizationId, orgId))
.orderBy(desc(walletRecordUpdates.createdAt))
.limit(limit);
return rows.map(toView);
}
export async function getUpdate(
orgId: string,
id: string,
): Promise<WalletUpdateView | null> {
const [row] = await db
.select()
.from(walletRecordUpdates)
.where(
and(
eq(walletRecordUpdates.id, id),
eq(walletRecordUpdates.organizationId, orgId),
),
);
return row ? toView(row) : null;
}
+4 -2
View File
@@ -7,9 +7,11 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
/* Append the Arabic face so Arabic codepoints fall through per-character even
in LTR locales; Latin text still renders in Inter. See app/layout.tsx. */
--font-sans: var(--font-sans), var(--font-arabic);
--font-mono: var(--font-mono);
--font-heading: var(--font-heading);
--font-heading: var(--font-heading), var(--font-arabic);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+30 -4
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
import { Geist_Mono, Inter } from "next/font/google";
import { Geist_Mono, IBM_Plex_Sans_Arabic, Inter } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
import { ThemeProvider } from "@/components/theme-provider";
@@ -10,6 +10,27 @@ import { ToastProvider } from "@/components/ui/toast";
const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const interHeading = Inter({ subsets: ["latin"], variable: "--font-heading" });
const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-mono" });
// Arabic-capable fallback: Inter has no Arabic glyphs, so we append this to the
// sans/heading stacks (see globals.css) for per-character fallback in every
// locale, and rely on it fully when dir="rtl". Not a variable font — pin weights.
const plexArabic = IBM_Plex_Sans_Arabic({
subsets: ["arabic"],
weight: ["400", "500", "600", "700"],
variable: "--font-arabic",
});
// Runs before first paint: mirror lib/i18n/config.ts `dirFor` so an Arabic user
// gets dir="rtl" immediately instead of a flash of LTR. Detection order matches
// i18next-browser-languagedetector (localStorage key "i18nextLng", then the
// browser language). suppressHydrationWarning on <html> ignores the attr diff.
const setInitialDir = `
(function(){try{
var l=localStorage.getItem("i18nextLng")||navigator.language||"en";
var e=document.documentElement;
e.lang=l;
e.dir=l.indexOf("ar")===0?"rtl":"ltr";
}catch(_){}})();
`;
export const metadata: Metadata = {
title: "temetro — AI assistant for clinicians",
@@ -33,17 +54,22 @@ export default function RootLayout({
inter.variable,
interHeading.variable,
geistMono.variable,
plexArabic.variable,
"font-sans"
)}
>
{/* suppressHydrationWarning: next-themes sets the theme class on <html>
before hydration, and browser extensions (e.g. ColorZilla's
cz-shortcut-listen) mutate <body>. Only ignores attribute diffs on
those elements, not their children. */}
before hydration, the dir script below sets lang/dir, and browser
extensions (e.g. ColorZilla's cz-shortcut-listen) mutate <body>. Only
ignores attribute diffs on those elements, not their children. */}
<body
className="h-dvh overflow-hidden flex flex-col"
suppressHydrationWarning
>
<script
// Sets <html dir/lang> before paint; see setInitialDir above.
dangerouslySetInnerHTML={{ __html: setInitialDir }}
/>
<ThemeProvider
attribute="class"
defaultTheme="dark"
@@ -104,7 +104,7 @@ function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline justify-between gap-3">
<span className="shrink-0 text-muted-foreground text-xs">{label}</span>
<span className="text-right text-foreground text-sm">{value}</span>
<span className="text-end text-foreground text-sm">{value}</span>
</div>
);
}
@@ -212,7 +212,7 @@ export function ActivityView() {
<button
className={cn(
"-mx-2 flex-1 rounded-lg px-2 py-1 text-left transition-colors hover:bg-accent/40",
"-mx-2 flex-1 rounded-lg px-2 py-1 text-start transition-colors hover:bg-accent/40",
isLast ? "pb-1" : "mb-5",
)}
onClick={() => setSelected(entry)}
+1 -1
View File
@@ -37,7 +37,7 @@ export function TrendCard({
return (
<>
<button
className="w-full text-left"
className="w-full text-start"
disabled={!hasData}
onClick={() => setOpen(true)}
type="button"
@@ -314,9 +314,9 @@ export function AppointmentsView() {
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
className="w-full ps-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("appointments.searchPlaceholder")}
value={query}
@@ -145,7 +145,7 @@ export function CalendarDialog({
type="button"
variant="ghost"
>
<ChevronLeft />
<ChevronLeft className="rtl:rotate-180" />
</Button>
<Button
aria-label="Next month"
@@ -154,7 +154,7 @@ export function CalendarDialog({
type="button"
variant="ghost"
>
<ChevronRight />
<ChevronRight className="rtl:rotate-180" />
</Button>
</div>
</div>
@@ -182,7 +182,7 @@ export function CalendarDialog({
return (
<button
className={cn(
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-left align-top transition-colors hover:bg-accent/50",
"flex min-h-22 flex-col gap-1 rounded-lg border p-1.5 text-start align-top transition-colors hover:bg-accent/50",
inMonth
? "bg-card/30"
: "bg-transparent text-muted-foreground/40",
@@ -5,6 +5,7 @@ import { type ReactNode, useEffect, useRef } from "react";
import { useAiAccess } from "@/lib/ai-policy";
import { authClient } from "@/lib/auth-client";
import { applyStoredLanguage } from "@/lib/language";
import { canAccessRoute, defaultLandingFor, useActiveRole } from "@/lib/roles";
// Authoritative client-side gate for the app shell. Requires a session and an
@@ -24,6 +25,16 @@ export function AppAuthGuard({ children }: { children: ReactNode }) {
const hasUser = Boolean(session?.user);
const activeOrgId = session?.session?.activeOrganizationId ?? null;
// Adopt the language saved on the backend once signed in, so the UI language
// roams across devices. Best-effort and one-shot; localStorage stays the
// offline source of truth.
const languageSynced = useRef(false);
useEffect(() => {
if (!hasUser || languageSynced.current) return;
languageSynced.current = true;
void applyStoredLanguage();
}, [hasUser]);
useEffect(() => {
if (isPending) return;
if (!hasUser) {
@@ -269,7 +269,7 @@ export function ActionPreviewCard({
</span>
{editable ? (
<Button
className="ml-auto"
className="ms-auto"
onClick={() => setEditOpen(true)}
size="sm"
variant="ghost"
+1 -1
View File
@@ -60,7 +60,7 @@ export function AiSetupNotice() {
</div>
<button
aria-label={t("chat.setupNotice.dismiss")}
className="-mr-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="-me-1 shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => setDismissed(true)}
type="button"
>
@@ -115,7 +115,7 @@ export function BatchActionPreviewCard({
<span className="font-medium text-sm">
{t("chat.actionCard.batch.title", { count: items.length })}
</span>
<Badge className="ml-auto gap-1" variant="secondary">
<Badge className="ms-auto gap-1" variant="secondary">
<Sparkles className="size-3" />
AI
</Badge>
@@ -104,9 +104,9 @@ export function ChatHistoryPanel() {
{t("chat.history.startNew")}
</Button>
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-2.5 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-2.5 size-4 text-muted-foreground" />
<Input
className="pl-8"
className="ps-8"
onChange={(e) => setQuery(e.target.value)}
placeholder={t("chat.history.search")}
value={query}
@@ -123,7 +123,7 @@ export function ChatHistoryPanel() {
return (
<button
className={cn(
"group flex items-center gap-2 rounded-md px-2 py-2 text-left text-sm transition-colors hover:bg-accent",
"group flex items-center gap-2 rounded-md px-2 py-2 text-start text-sm transition-colors hover:bg-accent",
active
? "bg-accent text-foreground"
: "text-muted-foreground",
+2 -2
View File
@@ -242,7 +242,7 @@ export function ChatInput({
/>
</label>
<button
className={cn(contextPill, "ml-0.5")}
className={cn(contextPill, "ms-0.5")}
onClick={() => {
setAddKey((k) => k + 1);
setAddOpen(true);
@@ -258,7 +258,7 @@ export function ChatInput({
<ModePicker
mode={mode}
onModeChange={onModeChange}
triggerClassName={cn(pillButton, "mr-1")}
triggerClassName={cn(pillButton, "me-1")}
/>
<button
aria-label={
+1 -1
View File
@@ -421,7 +421,7 @@ export function ChatPanel() {
</div>
<button
aria-label={t("chat.error.dismiss")}
className="-mr-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
className="-me-1 shrink-0 rounded-md p-1 text-destructive-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive-foreground"
onClick={() => setErrorDismissed(true)}
type="button"
>
@@ -196,7 +196,7 @@ export function ImportPreviewCard({
<span className="text-sm font-medium">{t("chat.importCard.title")}</span>
{status === "pending" && records.length > 0 ? (
<Button
className="ml-auto"
className="ms-auto"
onClick={() => setReviewOpen(true)}
size="sm"
variant="ghost"
@@ -289,7 +289,7 @@ export function ImportPreviewCard({
t("chat.importCard.unnamed");
return (
<button
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-left transition-colors hover:bg-accent"
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-start transition-colors hover:bg-accent"
key={index}
onClick={() => setEditingIndex(index)}
type="button"
@@ -16,7 +16,7 @@ export function InventoryListCard({ items }: { items: InventoryItem[] }) {
<div className="flex items-center gap-2 border-b px-4 py-3">
<Boxes className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{t("chat.lists.inventory")}</span>
<Badge className="ml-auto" variant="secondary">
<Badge className="ms-auto" variant="secondary">
{items.length}
</Badge>
</div>
+3 -3
View File
@@ -71,11 +71,11 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
// plus a subtle clickable affordance (they open a detail dialog). Compact cards
// size to their own (short) content — see `items-start` in PatientResult.
const rowCard =
"w-72 shrink-0 cursor-pointer gap-0 text-left outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
"w-72 shrink-0 cursor-pointer gap-0 text-start outline-none transition hover:bg-accent/30 hover:ring-foreground/20 focus-visible:ring-2 focus-visible:ring-ring";
// Same footprint as `rowCard` but with no clickable affordance — used when a
// card has nothing extra to reveal, so it shouldn't promise "Click for more".
const rowCardStatic = "w-72 shrink-0 gap-0 text-left";
const rowCardStatic = "w-72 shrink-0 gap-0 text-start";
// COSS Card has no `size` variant; recreate the old compact ("sm") density by
// tightening the inner section padding from p-6 → p-4 via data-slot selectors.
@@ -196,7 +196,7 @@ function ExpandableCard({
{children}
<div className="flex items-center gap-1 px-4 pt-2 pb-3 text-muted-foreground text-xs">
{t("patientCard.clickForMore")}
<ArrowRight className="size-3" />
<ArrowRight className="size-3 rtl:rotate-180" />
</div>
</DialogTrigger>
<DialogPopup className="max-h-[80dvh] sm:max-w-lg">
@@ -724,7 +724,7 @@ export function PatientFormDialog({
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
{error && (
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
<p className="text-sm text-destructive sm:me-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patientForm.cancel")}
@@ -31,7 +31,7 @@ function Shell({
<div className="flex items-center gap-2 border-b px-4 py-3">
<Icon className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
<Badge className="ml-auto" variant="secondary">
<Badge className="ms-auto" variant="secondary">
{rows.length}
</Badge>
</div>
@@ -123,7 +123,7 @@ export function AppointmentListCard({
{range ? (
<span className="text-muted-foreground text-xs">{range}</span>
) : null}
<Badge className="ml-auto" variant="secondary">
<Badge className="ms-auto" variant="secondary">
{appointments.length}
</Badge>
</div>
@@ -157,7 +157,7 @@ export function AppointmentListCard({
</span>
<Button render={<Link href={href} />} size="sm" variant="ghost">
{t("chat.lists.viewInCalendar")}
<ChevronRight className="size-4" />
<ChevronRight className="size-4 rtl:rotate-180" />
</Button>
</div>
</Card>
+18 -1
View File
@@ -1,10 +1,27 @@
"use client";
import { useEffect } from "react";
import type * as React from "react";
import { I18nextProvider } from "react-i18next";
import i18n from "@/lib/i18n/config";
import i18n, { dirFor } from "@/lib/i18n/config";
export function I18nProvider({ children }: { children: React.ReactNode }) {
// Keep <html lang/dir> in sync with the active language. The inline script in
// app/layout.tsx sets these before first paint (avoiding an RTL flash); this
// effect keeps them correct after hydration and on every language switch.
useEffect(() => {
const apply = (lng: string) => {
const root = document.documentElement;
root.lang = lng;
root.dir = dirFor(lng);
};
apply(i18n.resolvedLanguage ?? i18n.language);
i18n.on("languageChanged", apply);
return () => {
i18n.off("languageChanged", apply);
};
}, []);
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
}
@@ -171,7 +171,7 @@ export function InvoiceDetailSheet({
<SheetTitle className="flex items-center gap-2">
{invoice.number}
<AiBadge source={invoice.source} />
<Badge className="ml-auto" variant={statusVariant[invoice.status]}>
<Badge className="ms-auto" variant={statusVariant[invoice.status]}>
{t(`invoices.status.${invoice.status}`)}
</Badge>
</SheetTitle>
@@ -222,7 +222,7 @@ export function InvoiceDetailSheet({
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
{li.quantity} × {formatMoney(li.unitPrice)}
</span>
<span className="w-20 shrink-0 text-right font-medium text-foreground tabular-nums">
<span className="w-20 shrink-0 text-end font-medium text-foreground tabular-nums">
{formatMoney(li.quantity * li.unitPrice)}
</span>
</div>
@@ -135,9 +135,9 @@ export function InvoicesView() {
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
className="w-full ps-9 sm:w-64"
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
@@ -180,7 +180,7 @@ export function InvoicesView() {
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{pageRows.map((inv) => (
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
className="flex w-full items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-accent/50"
key={inv.id}
onClick={() => openInvoice(inv)}
type="button"
@@ -92,7 +92,7 @@ export function LabIntegrationCard({
{t("integrations.fhir.cardTitle")}
</h2>
<Badge
className="ml-auto"
className="ms-auto"
variant={
config.status === "connected"
? "secondary"
@@ -141,9 +141,9 @@ export function LabIntegrationCard({
) : (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="pl-9"
className="ps-9"
onChange={(e) => setQuery(e.target.value)}
placeholder={t("integrations.fhir.searchPlaceholder")}
value={query}
@@ -153,7 +153,7 @@ export function LabIntegrationCard({
<div className="flex flex-col gap-1">
{matches.map((p) => (
<button
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
className="flex items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
key={p.fileNumber}
onClick={() => setSelected(p)}
type="button"
+5 -5
View File
@@ -336,7 +336,7 @@ function AddResultDialog({
{t("lab.addResult.patient")}
</span>
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
aria-activedescendant={
matches[activeIndex]
@@ -344,7 +344,7 @@ function AddResultDialog({
: undefined
}
autoFocus
className="pl-9"
className="ps-9"
onChange={(event) => {
setPatientQuery(event.target.value);
setActiveIndex(0);
@@ -359,7 +359,7 @@ function AddResultDialog({
{matches.map((p, index) => (
<button
className={cn(
"flex items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors",
"flex items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors",
index === activeIndex
? "bg-accent"
: "hover:bg-accent",
@@ -650,7 +650,7 @@ export function LabView() {
}
onClick={() => toggle(task.id)}
/>
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-left">
<CollapsibleTrigger className="group flex min-w-0 flex-1 items-center gap-3 text-start">
<div className="flex min-w-0 flex-1 flex-col">
<span
className={cn(
@@ -679,7 +679,7 @@ export function LabView() {
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="space-y-3 px-4 pb-4 pl-12 text-sm">
<div className="space-y-3 px-4 pb-4 ps-12 text-sm">
<p
className={cn(
"whitespace-pre-wrap",
+1 -1
View File
@@ -124,7 +124,7 @@ export function LoginForm({
{t("auth.login.passwordLabel")}
</FieldLabel>
<Link
className="ml-auto inline-block text-sm underline-offset-4 hover:underline"
className="ms-auto inline-block text-sm underline-offset-4 hover:underline"
href="/forgot-password"
>
{t("auth.login.forgotPassword")}
@@ -285,10 +285,10 @@ export function MeetingRoom({
</DialogHeader>
<DialogPanel className="flex flex-col gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
aria-label={t("meetings.invite.search")}
className="pl-9"
className="ps-9"
onChange={(e) => setMemberQuery(e.target.value)}
placeholder={t("meetings.invite.search")}
size="sm"
@@ -246,13 +246,13 @@ export function MeetingsView() {
return (
<div
className={cn(
"group flex w-full items-center gap-1 rounded-lg pr-1 transition-colors hover:bg-accent/50",
"group flex w-full items-center gap-1 rounded-lg pe-1 transition-colors hover:bg-accent/50",
activeRoom?.id === room.id && "bg-accent hover:bg-accent",
)}
key={room.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-left"
className="flex min-w-0 flex-1 items-center gap-2.5 rounded-lg px-2 py-2 text-start"
onClick={() => setActiveRoom(room)}
type="button"
>
@@ -352,7 +352,7 @@ export function MeetingsView() {
<div className="flex flex-col gap-1">
{upcoming.map((e) => (
<button
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-left transition-colors hover:bg-accent/50"
className="flex flex-col gap-0.5 rounded-xl border bg-card px-2.5 py-2 text-start transition-colors hover:bg-accent/50"
key={e.id}
onClick={() => setSelectedDay(new Date(`${e.date}T00:00:00`))}
type="button"
@@ -159,7 +159,7 @@ export function ScheduleMeetingDialog({
return (
<button
className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent/50",
"flex items-center gap-3 rounded-lg px-2 py-1.5 text-start transition-colors hover:bg-accent/50",
on && "bg-accent",
)}
key={m.id}
@@ -28,7 +28,7 @@ function Row({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-3">
<dt className="text-muted-foreground text-sm">{label}</dt>
<dd className="text-right text-foreground text-sm">{value}</dd>
<dd className="text-end text-foreground text-sm">{value}</dd>
</div>
);
}
+11 -11
View File
@@ -152,7 +152,7 @@ function SentAttachment({ att }: { att: MessageAttachment }) {
<AttachmentContent>
<AttachmentTitle>{att.fileName}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions className="pr-1.5">
<AttachmentActions className="pe-1.5">
<AttachmentAction
aria-label={t("messages.attach.download")}
onClick={() => {
@@ -491,10 +491,10 @@ export function MessagesView() {
</div>
<div className="border-border border-b px-3 py-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
aria-label={t("messages.searchPlaceholder")}
className="pl-9"
className="ps-9"
onChange={(e) => setInboxQuery(e.target.value)}
placeholder={t("messages.searchPlaceholder")}
size="sm"
@@ -520,7 +520,7 @@ export function MessagesView() {
return (
<div
className={cn(
"flex w-full items-center gap-1 rounded-lg pr-2 transition-colors hover:bg-accent/50",
"flex w-full items-center gap-1 rounded-lg pe-2 transition-colors hover:bg-accent/50",
selected?.id === c.id && "bg-accent hover:bg-accent",
)}
key={c.id}
@@ -544,7 +544,7 @@ export function MessagesView() {
</button>
)}
<button
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pr-1 text-left"
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg py-2 pe-1 text-start"
onClick={() => open(c.id)}
type="button"
>
@@ -880,10 +880,10 @@ export function MessagesView() {
</DialogHeader>
<DialogPanel className="flex flex-col gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
aria-label={t("messages.compose.searchPlaceholder")}
className="pl-9"
className="ps-9"
onChange={(e) => setMemberQuery(e.target.value)}
placeholder={t("messages.compose.searchPlaceholder")}
size="sm"
@@ -902,7 +902,7 @@ export function MessagesView() {
) : (
visibleMembers.map((m) => (
<button
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
key={m.id}
onClick={() => startConversation(m.id)}
type="button"
@@ -932,10 +932,10 @@ export function MessagesView() {
</DialogHeader>
<DialogPanel className="flex flex-col gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
aria-label={t("messages.attach.apptSearchPlaceholder")}
className="pl-9"
className="ps-9"
onChange={(e) => setApptQuery(e.target.value)}
placeholder={t("messages.attach.apptSearchPlaceholder")}
size="sm"
@@ -954,7 +954,7 @@ export function MessagesView() {
) : (
visibleAppts.map((a) => (
<button
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-left transition-colors hover:bg-accent"
className="flex w-full flex-col gap-0.5 rounded-lg px-2 py-2 text-start transition-colors hover:bg-accent"
key={a.id}
onClick={() => attachAppointment(a)}
type="button"
+1 -1
View File
@@ -153,7 +153,7 @@ export function NotesView() {
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{notes.map((n) => (
<button
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-left transition-colors hover:bg-accent/50"
className="flex w-full flex-col items-start gap-0.5 px-4 py-3 text-start transition-colors hover:bg-accent/50"
key={n.id}
onClick={() => openNote(n)}
type="button"
@@ -7,7 +7,9 @@ import { AiBadge } from "@/components/ai-badge";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { RecordGraph } from "@/components/graph/record-graph";
import { PatientDetail } from "@/components/patients/patient-detail";
import { ScribeDialog } from "@/components/patients/scribe-dialog";
import { TransferPatientDialog } from "@/components/patients/transfer-patient-dialog";
import { WalletPushDialog } from "@/components/patients/wallet-push-dialog";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Dialog,
@@ -28,8 +30,10 @@ import { type Appointment, listAppointments } from "@/lib/appointments";
import { type Invoice, listInvoices } from "@/lib/invoices";
import { deletePatient, getPatient, type Patient } from "@/lib/patients";
import { listPrescriptions, type Prescription } from "@/lib/prescriptions";
import { useAiAccess } from "@/lib/ai-policy";
import { hasClinicalAccess, useActiveRole } from "@/lib/roles";
import { notify } from "@/lib/toast";
import { getWalletLink } from "@/lib/wallet-updates";
type Status = "loading" | "ready" | "not-found";
@@ -76,9 +80,17 @@ export function PatientDetailSheet({
// Deleting a chart is destructive — only offer it once we know the role is
// a full clinician (patient:delete), never optimistically.
const canDelete = role != null && hasClinicalAccess(role);
// The ambient scribe writes a clinical note, so it needs full clinical write
// access AND the clinic's AI must be enabled for this member.
const { allowed: aiAllowed } = useAiAccess();
const canScribe = role != null && hasClinicalAccess(role) && aiAllowed;
const [patient, setPatient] = useState<Patient | null>(null);
const [status, setStatus] = useState<Status>("loading");
const [editOpen, setEditOpen] = useState(false);
const [scribeOpen, setScribeOpen] = useState(false);
const [walletPushOpen, setWalletPushOpen] = useState(false);
// Set once we confirm this patient is linked to a wallet (permanent share).
const [walletLinked, setWalletLinked] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
// Graph popped out of the sheet into its own dialog (the sheet closes first).
@@ -124,6 +136,21 @@ export function PatientDetailSheet({
};
}, [open, fileNumber]);
// Whether this patient is wallet-linked (drives the "Push update" button).
// Separate from the main load so it re-checks once the role resolves without
// refetching the record. Only clinicians can push.
useEffect(() => {
setWalletLinked(false);
if (!open || !fileNumber || !hasClinicalAccess(role)) return;
let active = true;
getWalletLink(fileNumber)
.then(() => active && setWalletLinked(true))
.catch(() => {});
return () => {
active = false;
};
}, [open, fileNumber, role]);
const remove = async () => {
if (!patient) return;
try {
@@ -171,6 +198,10 @@ export function PatientDetailSheet({
setEditKey((k) => k + 1);
setEditOpen(true);
}}
onScribe={canScribe ? () => setScribeOpen(true) : undefined}
onWalletPush={
walletLinked ? () => setWalletPushOpen(true) : undefined
}
onOpenGraph={() => {
onOpenChange(false);
setGraphOpen(true);
@@ -197,6 +228,23 @@ export function PatientDetailSheet({
/>
)}
{patient && (
<ScribeDialog
onOpenChange={setScribeOpen}
onSaved={(updated) => setPatient(updated)}
open={scribeOpen}
patient={patient}
/>
)}
{patient && (
<WalletPushDialog
onOpenChange={setWalletPushOpen}
open={walletPushOpen}
patient={patient}
/>
)}
{patient && (
<TransferPatientDialog
onOpenChange={setTransferOpen}
@@ -1,6 +1,14 @@
"use client";
import { ArrowLeftRight, FileDown, Network, Pencil, Trash2 } from "lucide-react";
import {
ArrowLeftRight,
FileDown,
Mic,
Network,
Pencil,
Send,
Trash2,
} from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -173,6 +181,8 @@ function RecordHistory({ fileNumber }: { fileNumber: string }) {
export function PatientDetail({
patient,
onEdit,
onScribe,
onWalletPush,
onTransfer,
onDelete,
onOpenGraph,
@@ -182,6 +192,10 @@ export function PatientDetail({
}: {
patient: Patient;
onEdit?: () => void;
// Opens the ambient AI visit scribe (record/transcribe → draft note).
onScribe?: () => void;
// Pushes the record to the patient's wallet (only when wallet-linked).
onWalletPush?: () => void;
onTransfer?: () => void;
onDelete?: () => void;
// Pops the record graph out into its own dialog (closing this sheet).
@@ -287,16 +301,33 @@ export function PatientDetail({
{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="ml-auto"
className="ms-auto"
onClick={onDelete}
size="sm"
type="button"
@@ -358,7 +389,7 @@ export function PatientDetail({
<div className="divide-y divide-border overflow-hidden rounded-xl border bg-card/30">
{files.map((file) => (
<button
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-accent"
className="flex w-full items-center gap-2.5 px-3 py-2 text-start transition-colors hover:bg-accent"
key={file.id}
onClick={() => setOpenFile(file)}
type="button"
@@ -210,7 +210,7 @@ export function AttachmentsSection({
key={attachment.id}
>
<button
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
className="flex min-w-0 flex-1 items-center gap-2.5 text-start"
onClick={() => setPreview(attachment)}
type="button"
>
@@ -115,9 +115,9 @@ export function PatientsView() {
</h1>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
className="w-full ps-9 sm:w-64"
onChange={(event) => {
setQuery(event.target.value);
setPage(1);
@@ -159,7 +159,7 @@ export function PatientsView() {
<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-left text-xs text-muted-foreground uppercase">
<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">
@@ -0,0 +1,436 @@
"use client";
import { Mic, Square, Sparkles, Trash2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import {
Tabs,
TabsList,
TabsPanel,
TabsTab,
} from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { uploadAttachment } from "@/lib/attachments";
import type { Encounter, Patient } from "@/lib/patients";
import { draftNote, saveNote, transcribeRecording } from "@/lib/scribe";
import { notify } from "@/lib/toast";
import { ApiError } from "@/lib/api-client";
type Phase = "input" | "processing" | "review";
type InputTab = "record" | "paste";
type RecState = "idle" | "recording" | "recorded";
// Pick a supported audio mime for MediaRecorder (Opus in WebM/OGG is tiny for
// speech; Safari falls back to mp4). Returns "" to let the browser choose.
function pickAudioMime(): string {
if (typeof MediaRecorder === "undefined") return "";
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/mp4",
];
return candidates.find((m) => MediaRecorder.isTypeSupported(m)) ?? "";
}
function fmtElapsed(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
// The ambient AI scribe: record or paste a visit conversation, draft a SOAP
// encounter note, review it, and append it to the patient record.
export function ScribeDialog({
patient,
open,
onOpenChange,
onSaved,
}: {
patient: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (updated: Patient) => void;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("input");
const [tab, setTab] = useState<InputTab>("record");
const [recState, setRecState] = useState<RecState>("idle");
const [elapsed, setElapsed] = useState(0);
const [transcript, setTranscript] = useState("");
const [visitType, setVisitType] = useState("");
const [draft, setDraft] = useState<Encounter | null>(null);
const [veilNote, setVeilNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const mediaRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const blobRef = useRef<Blob | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Tear down any live recording + object state when the dialog closes.
const stopTracks = () => {
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
mediaRef.current = null;
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
// Stop tracks on unmount.
useEffect(() => () => stopTracks(), []);
// Reset all state for the next open. Called on every close (the parent only
// ever closes this dialog through our onOpenChange), so no reset-in-effect.
const reset = () => {
setPhase("input");
setTab("record");
setRecState("idle");
setElapsed(0);
setTranscript("");
setVisitType("");
setDraft(null);
setVeilNote(null);
setError(null);
chunksRef.current = [];
blobRef.current = null;
};
const handleOpenChange = (next: boolean) => {
if (!next) {
stopTracks();
reset();
}
onOpenChange(next);
};
const startRecording = async () => {
setError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mime = pickAudioMime();
const recorder = new MediaRecorder(
stream,
mime ? { mimeType: mime } : undefined,
);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
blobRef.current = new Blob(chunksRef.current, {
type: recorder.mimeType || "audio/webm",
});
setRecState("recorded");
};
recorder.start();
mediaRef.current = recorder;
setRecState("recording");
setElapsed(0);
timerRef.current = setInterval(() => setElapsed((s) => s + 1), 1000);
} catch {
setError(t("scribe.errors.mic"));
}
};
const stopRecording = () => {
mediaRef.current?.stop();
mediaRef.current?.stream.getTracks().forEach((track) => track.stop());
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = null;
};
const discardRecording = () => {
blobRef.current = null;
chunksRef.current = [];
setRecState("idle");
setElapsed(0);
};
const filenameFor = (blob: Blob): string => {
const ext = blob.type.includes("mp4")
? "m4a"
: blob.type.includes("ogg")
? "ogg"
: "webm";
return `visit-${patient.fileNumber}-${Date.now()}.${ext}`;
};
const generate = async () => {
setError(null);
setPhase("processing");
try {
let text = transcript.trim();
if (tab === "record") {
const blob = blobRef.current;
if (!blob) {
setError(t("scribe.errors.noRecording"));
setPhase("input");
return;
}
// Store the recording as a patient attachment (auditable), then
// transcribe it server-side.
const file = new File([blob], filenameFor(blob), { type: blob.type });
const attachment = await uploadAttachment({
file,
fileNumber: patient.fileNumber,
labKey: "scribe",
});
const res = await transcribeRecording(attachment.id);
text = res.transcript.trim();
setTranscript(text);
}
if (!text) {
setError(t("scribe.errors.empty"));
setPhase("input");
return;
}
const { draft: note, veil } = await draftNote({
fileNumber: patient.fileNumber,
transcript: text,
visitType: visitType.trim() || undefined,
});
setDraft(note);
setVeilNote(
veil.active ? t("scribe.review.veil", { provider: veil.provider }) : null,
);
setPhase("review");
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
);
setPhase("input");
}
};
const save = async () => {
if (!draft) return;
setPhase("processing");
try {
const updated = await saveNote(patient.fileNumber, draft);
notify.success(t("scribe.saved.title"), patient.name);
onSaved(updated);
handleOpenChange(false);
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("scribe.errors.generic"),
);
setPhase("review");
}
};
const busy = phase === "processing";
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
{t("scribe.title")}
</DialogTitle>
<DialogDescription>
{t("scribe.subtitle", { name: patient.name })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
{phase === "review" && draft ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-type">{t("scribe.review.type")}</Label>
<Input
id="scribe-type"
onChange={(e) =>
setDraft({ ...draft, type: e.target.value })
}
value={draft.type}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-date">{t("scribe.review.date")}</Label>
<Input
id="scribe-date"
onChange={(e) =>
setDraft({ ...draft, date: e.target.value })
}
type="date"
value={draft.date}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-summary">
{t("scribe.review.summary")}
</Label>
<Textarea
className="min-h-56"
id="scribe-summary"
onChange={(e) =>
setDraft({ ...draft, summary: e.target.value })
}
value={draft.summary}
/>
</div>
<p className="text-muted-foreground text-xs">
{t("scribe.review.provider", { provider: draft.provider })}
</p>
{veilNote && (
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{veilNote}
</p>
)}
</div>
) : (
<Tabs
onValueChange={(v) => setTab(v as InputTab)}
value={tab}
>
<TabsList className="w-full">
<TabsTab value="record">
<Mic className="size-4" />
{t("scribe.tabs.record")}
</TabsTab>
<TabsTab value="paste">{t("scribe.tabs.paste")}</TabsTab>
</TabsList>
<TabsPanel className="pt-3" value="record">
<div className="flex flex-col items-center gap-4 py-4">
{recState === "recording" ? (
<>
<div className="flex items-center gap-2 text-destructive">
<span className="size-2.5 animate-pulse rounded-full bg-destructive" />
<span className="font-mono text-lg tabular-nums">
{fmtElapsed(elapsed)}
</span>
</div>
<Button
onClick={stopRecording}
type="button"
variant="destructive"
>
<Square className="size-4" />
{t("scribe.record.stop")}
</Button>
</>
) : recState === "recorded" ? (
<>
<p className="text-foreground text-sm">
{t("scribe.record.ready", {
duration: fmtElapsed(elapsed),
})}
</p>
<Button
onClick={discardRecording}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
{t("scribe.record.discard")}
</Button>
</>
) : (
<Button onClick={startRecording} type="button">
<Mic className="size-4" />
{t("scribe.record.start")}
</Button>
)}
</div>
</TabsPanel>
<TabsPanel className="pt-3" value="paste">
<Textarea
className="min-h-40"
onChange={(e) => setTranscript(e.target.value)}
placeholder={t("scribe.paste.placeholder")}
value={transcript}
/>
</TabsPanel>
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="scribe-visit-type">
{t("scribe.visitType.label")}
</Label>
<Input
id="scribe-visit-type"
onChange={(e) => setVisitType(e.target.value)}
placeholder={t("scribe.visitType.placeholder")}
value={visitType}
/>
</div>
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{t("scribe.consent")}
</p>
</div>
</Tabs>
)}
{error && (
<p className="mt-3 text-destructive text-sm" role="alert">
{error}
</p>
)}
</DialogPanel>
<DialogFooter>
{phase === "review" ? (
<>
<Button
disabled={busy}
onClick={() => setPhase("input")}
type="button"
variant="outline"
>
{t("scribe.review.back")}
</Button>
<Button disabled={busy} onClick={save} type="button">
{busy && <Spinner className="size-4" />}
{t("scribe.review.save")}
</Button>
</>
) : (
<>
<Button
disabled={busy}
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
{t("scribe.cancel")}
</Button>
<Button
disabled={
busy ||
(tab === "record"
? recState !== "recorded"
: transcript.trim().length === 0)
}
onClick={generate}
type="button"
>
{busy && <Spinner className="size-4" />}
{busy ? t("scribe.processing") : t("scribe.generate")}
</Button>
</>
)}
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -0,0 +1,242 @@
"use client";
import { Check, Loader2, Send, X } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { ApiError } from "@/lib/api-client";
import type { Patient } from "@/lib/patients";
import { cn } from "@/lib/utils";
import {
getWalletUpdate,
pushWalletUpdate,
type WalletUpdate,
} from "@/lib/wallet-updates";
// The record sections a clinician can flag as changed. The labels double as the
// human-readable change summary the patient sees when approving.
const SECTION_KEYS = [
"demographics",
"problems",
"medications",
"allergies",
"labs",
"vitals",
"visits",
] as const;
type Phase = "compose" | "sent";
// Push the current record to a wallet-linked patient's app. The patient must
// approve it on their phone before their on-device record is replaced.
export function WalletPushDialog({
patient,
open,
onOpenChange,
}: {
patient: Patient;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<Phase>("compose");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [note, setNote] = useState("");
const [update, setUpdate] = useState<WalletUpdate | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const reset = () => {
setPhase("compose");
setSelected(new Set());
setNote("");
setUpdate(null);
setBusy(false);
setError(null);
};
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
// Poll the update's status until the patient approves/denies (or the dialog
// closes). Live pushes usually resolve within seconds.
useEffect(() => {
if (phase !== "sent" || !update || update.resolvedAt) return;
let active = true;
const timer = setInterval(async () => {
try {
const fresh = await getWalletUpdate(update.id);
if (!active) return;
setUpdate(fresh);
if (fresh.resolvedAt) clearInterval(timer);
} catch {
/* keep polling */
}
}, 3000);
return () => {
active = false;
clearInterval(timer);
};
}, [phase, update]);
const toggle = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const push = async () => {
setBusy(true);
setError(null);
try {
const changes = [
...[...selected].map((k) => t(`walletPush.sections.${k}`)),
...(note.trim() ? [note.trim()] : []),
];
const created = await pushWalletUpdate({
fileNumber: patient.fileNumber,
changes,
});
setUpdate(created);
setPhase("sent");
} catch (err) {
setError(
err instanceof ApiError ? err.message : t("walletPush.errors.generic"),
);
} finally {
setBusy(false);
}
};
const canPush = selected.size > 0 || note.trim().length > 0;
const status = update?.status ?? "pending";
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Send className="size-4 text-primary" />
{t("walletPush.title")}
</DialogTitle>
<DialogDescription>
{t("walletPush.subtitle", { name: patient.name })}
</DialogDescription>
</DialogHeader>
<DialogPanel className="min-h-0 flex-1 overflow-y-auto">
{phase === "compose" ? (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label>{t("walletPush.sectionsLabel")}</Label>
<div className="flex flex-wrap gap-2">
{SECTION_KEYS.map((key) => {
const on = selected.has(key);
return (
<button
className={cn(
"rounded-full border px-3 py-1 text-sm transition-colors",
on
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:bg-accent",
)}
key={key}
onClick={() => toggle(key)}
type="button"
>
{t(`walletPush.sections.${key}`)}
</button>
);
})}
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="wallet-push-note">
{t("walletPush.noteLabel")}
</Label>
<Textarea
className="min-h-20"
id="wallet-push-note"
onChange={(e) => setNote(e.target.value)}
placeholder={t("walletPush.notePlaceholder")}
value={note}
/>
</div>
<p className="rounded-lg bg-muted px-3 py-2 text-muted-foreground text-xs">
{t("walletPush.notice")}
</p>
</div>
) : (
<div className="flex flex-col items-center gap-4 py-6 text-center">
{status === "approved" ? (
<div className="flex size-12 items-center justify-center rounded-full bg-success/15 text-success">
<Check className="size-6" />
</div>
) : status === "denied" ? (
<div className="flex size-12 items-center justify-center rounded-full bg-destructive/15 text-destructive">
<X className="size-6" />
</div>
) : (
<Loader2 className="size-8 animate-spin text-muted-foreground" />
)}
<div className="flex flex-col gap-1">
<p className="font-medium text-foreground text-sm">
{t(`walletPush.status.${status}.title`)}
</p>
<p className="text-muted-foreground text-sm">
{t(`walletPush.status.${status}.body`)}
</p>
</div>
</div>
)}
{error && (
<p className="mt-3 text-destructive text-sm" role="alert">
{error}
</p>
)}
</DialogPanel>
<DialogFooter>
{phase === "compose" ? (
<>
<Button
onClick={() => handleOpenChange(false)}
type="button"
variant="outline"
>
{t("walletPush.cancel")}
</Button>
<Button disabled={busy || !canPush} onClick={push} type="button">
{busy && <Spinner className="size-4" />}
{t("walletPush.send")}
</Button>
</>
) : (
<Button onClick={() => handleOpenChange(false)} type="button">
{t("walletPush.done")}
</Button>
)}
</DialogFooter>
</DialogPopup>
</Dialog>
);
}
@@ -35,7 +35,7 @@ function Row({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 py-2">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-right text-foreground text-sm">{value}</span>
<span className="text-end text-foreground text-sm">{value}</span>
</div>
);
}
@@ -68,7 +68,7 @@ export function InventoryDetailDialog({
</span>
<span className="min-w-0 truncate">{item.name}</span>
<Badge
className="ml-auto shrink-0"
className="ms-auto shrink-0"
variant={availabilityVariant[availability]}
>
{t(`inventory.availability.${availability}`)}
@@ -67,7 +67,7 @@ function ItemRow({
const descriptor = [item.strength, item.form].filter(Boolean).join(" · ");
return (
<div
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-accent/50"
className="flex w-full cursor-pointer items-center gap-3 px-4 py-3 text-start transition-colors hover:bg-accent/50"
onClick={onOpen}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
@@ -205,9 +205,9 @@ export function InventoryView() {
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
className="w-full ps-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("inventory.searchPlaceholder")}
value={query}
@@ -330,9 +330,9 @@ export function PharmacyView() {
<p className="text-muted-foreground text-sm">{t("pharmacy.subtitle")}</p>
</div>
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
className="w-full ps-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("pharmacy.searchPlaceholder")}
value={query}
+4 -4
View File
@@ -43,7 +43,7 @@ function Field({
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-1.5 text-left">
<label className="flex flex-col gap-1.5 text-start">
<span className="font-medium text-foreground text-sm">{label}</span>
{children}
</label>
@@ -122,7 +122,7 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
<div className="grid w-full gap-4 sm:grid-cols-2">
{cards.map((c) => (
<button
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-left transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="group flex flex-col items-start gap-4 rounded-3xl border bg-card/40 p-6 text-start transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
key={c.step}
onClick={() => onPick(c.step)}
type="button"
@@ -133,7 +133,7 @@ function ChooseStep({ onPick }: { onPick: (step: Step) => void }) {
<span className="flex flex-col gap-1">
<span className="flex items-center gap-1 font-semibold text-lg text-foreground">
{c.title}
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5 rtl:rotate-180" />
</span>
<span className="text-muted-foreground text-sm">{c.desc}</span>
</span>
@@ -151,7 +151,7 @@ function BackButton({ onBack }: { onBack: () => void }) {
onClick={onBack}
type="button"
>
<ArrowLeft className="size-4" />
<ArrowLeft className="size-4 rtl:rotate-180" />
{t("portal.back")}
</button>
);
@@ -378,11 +378,11 @@ export function AddPrescriptionDialog({
) : (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
aria-autocomplete="list"
autoFocus
className="pl-9"
className="ps-9"
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onPatientKeyDown}
placeholder={t("prescriptions.dialog.searchPlaceholder")}
@@ -399,7 +399,7 @@ export function AddPrescriptionDialog({
matches.map((p, i) => (
<button
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-start transition-colors",
i === activeIndex
? "bg-accent"
: "hover:bg-accent",
@@ -442,7 +442,7 @@ export function AddPrescriptionDialog({
return (
<button
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors",
"flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-start transition-colors",
i === medIndex ? "bg-accent" : "hover:bg-accent",
)}
// Use onMouseDown so the pick fires before the input's
@@ -147,7 +147,7 @@ export function PrescriptionDetailSheet({
<SheetFooter>
{onDelete && (
<Button
className="sm:mr-auto"
className="sm:me-auto"
onClick={onDelete}
type="button"
variant="destructive"
@@ -244,9 +244,9 @@ export function PrescriptionsView() {
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Search className="-translate-y-1/2 absolute top-1/2 start-3 size-4 text-muted-foreground" />
<Input
className="w-full pl-9 sm:w-64"
className="w-full ps-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("prescriptions.searchPlaceholder")}
value={query}
@@ -422,7 +422,7 @@ export function EmployeeDetailDialog({
<DialogFooter>
{editable && member && (
<Button
className="sm:mr-auto"
className="sm:me-auto"
onClick={() => onRemove(member)}
type="button"
variant="destructive"
@@ -19,6 +19,7 @@ import {
type SharedRecord,
type SigningKey,
} from "@/lib/signing";
import { listWalletUpdates, type WalletUpdate } from "@/lib/wallet-updates";
import { notify } from "@/lib/toast";
function formatDate(iso: string): string {
@@ -33,17 +34,23 @@ export function SigningPanel() {
const { t } = useTranslation();
const [key, setKey] = useState<SigningKey | null>(null);
const [records, setRecords] = useState<SharedRecord[]>([]);
const [updates, setUpdates] = useState<WalletUpdate[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [rotating, setRotating] = useState(false);
useEffect(() => {
let active = true;
Promise.all([getSigningKey(), listSignedRecords().catch(() => [])])
.then(([k, r]) => {
Promise.all([
getSigningKey(),
listSignedRecords().catch(() => []),
listWalletUpdates().catch(() => []),
])
.then(([k, r, u]) => {
if (!active) return;
setKey(k);
setRecords(r);
setUpdates(u);
setError(null);
})
.catch(() => {
@@ -109,7 +116,7 @@ export function SigningPanel() {
: t("settings.signing.rotateKey")}
</Button>
</div>
<div className="sm:text-right">
<div className="sm:text-end">
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
<p className="text-sm text-muted-foreground">
{key
@@ -223,6 +230,40 @@ export function SigningPanel() {
</SettingsCard>
)}
</SettingsSection>
<SettingsSection
description={t("walletUpdatesList.description")}
title={t("walletUpdatesList.title")}
>
{updates.length === 0 ? (
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
{t("walletUpdatesList.none")}
</p>
</SettingsCard>
) : (
<SettingsCard className="divide-y divide-border">
{updates.map((update) => (
<div
className="flex items-center justify-between gap-3 px-4 py-3.5"
key={update.id}
>
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm">
{update.changes.join(" · ") || `#${update.fileNumber}`}
</p>
<p className="truncate font-mono text-xs text-muted-foreground">
#{update.fileNumber} · {formatDate(update.createdAt)}
</p>
</div>
<Badge variant="secondary">
{t(`walletPush.status.${update.status}.title`)}
</Badge>
</div>
))}
</SettingsCard>
)}
</SettingsSection>
</>
);
}
@@ -164,11 +164,11 @@ export function CareTeamPanel({
{initials(m.name, m.email)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 text-left">
<div className="min-w-0 flex-1 text-start">
<p className="truncate text-sm font-medium">
{m.name || m.email || m.userId}
{isSelf && (
<span className="ml-1 text-xs text-muted-foreground">
<span className="ms-1 text-xs text-muted-foreground">
{t("settings.careTeam.you")}
</span>
)}
@@ -179,7 +179,7 @@ function IntegrationCard({
: t("settings.integrations.test")}
</Button>
{config.lastSyncAt ? (
<span className="ml-auto text-xs text-muted-foreground">
<span className="ms-auto text-xs text-muted-foreground">
{t("settings.integrations.lastSync", {
when: new Date(config.lastSyncAt).toLocaleString(),
})}
@@ -108,7 +108,7 @@ export function CopyField({
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</div>
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pr-1 pl-3 sm:w-80">
<div className="flex h-9 w-full items-center gap-2 rounded-3xl bg-input/50 pe-1 ps-3 sm:w-80">
<span className="flex-1 truncate text-sm text-muted-foreground">
{value}
</span>
@@ -25,6 +25,7 @@ import {
} from "@/components/settings/settings-parts";
import { authClient } from "@/lib/auth-client";
import { supportedLanguages } from "@/lib/i18n/config";
import { persistLanguage } from "@/lib/language";
import {
getSettings,
saveSettings,
@@ -341,7 +342,10 @@ export function ProfilePanel() {
: undefined
}
onConfirm={() => {
if (pendingLang) void i18n.changeLanguage(pendingLang);
if (pendingLang) {
void i18n.changeLanguage(pendingLang);
void persistLanguage(pendingLang);
}
}}
onOpenChange={(open) => {
if (!open) setPendingLang(null);
@@ -21,7 +21,7 @@ export function MobileSidebarTrigger() {
return (
<Button
aria-label={t("nav.openSidebar")}
className="fixed top-3 right-3 z-50 size-9 rounded-full bg-background/80 shadow-sm backdrop-blur md:hidden"
className="fixed top-3 end-3 z-50 size-9 rounded-full bg-background/80 shadow-sm backdrop-blur md:hidden"
onClick={toggleSidebar}
size="icon"
variant="outline"
+1 -1
View File
@@ -66,7 +66,7 @@ export default function DashboardNavigation({ routes }: { routes: Route[] }) {
>
{route.icon}
{!isCollapsed && (
<span className="ml-2 flex-1 truncate font-medium text-sm">
<span className="ms-2 flex-1 truncate font-medium text-sm">
{route.title}
</span>
)}
@@ -53,7 +53,7 @@ export function NotificationsPopover() {
>
<BellIcon className="size-5" />
{unread > 0 && (
<span className="-top-0.5 -right-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
<span className="-top-0.5 -end-0.5 absolute flex min-w-4 items-center justify-center rounded-full bg-primary px-1 font-medium text-[10px] text-primary-foreground leading-4">
{unread > 9 ? "9+" : unread}
</span>
)}
+3 -3
View File
@@ -146,13 +146,13 @@ export function NavUser() {
</Avatar>
{!isCollapsed && (
<>
<div className="grid flex-1 text-left text-sm leading-tight">
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-muted-foreground text-xs">
{secondary}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
<ChevronsUpDown className="ms-auto size-4" />
</>
)}
</MenuTrigger>
@@ -167,7 +167,7 @@ export function NavUser() {
<Avatar className="size-8">
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<div className="grid flex-1 text-start text-sm leading-tight">
<span className="truncate font-medium">{name}</span>
<span className="truncate text-muted-foreground text-xs">
{secondary}
+1 -1
View File
@@ -33,7 +33,7 @@ export function AccordionTrigger({
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
className={cn(
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-left font-medium text-sm outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180",
"flex flex-1 cursor-pointer items-start justify-between gap-4 rounded-md py-4 text-start font-medium text-sm outline-none transition-all focus-visible:ring-[3px] focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-64 data-panel-open:*:data-[slot=accordion-indicator]:rotate-180",
className,
)}
data-slot="accordion-trigger"
+2 -2
View File
@@ -94,8 +94,8 @@ const bubbleReactionsVariants = cva(
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
start: "start-3",
end: "end-3",
},
},
defaultVariants: {
+2 -2
View File
@@ -76,7 +76,7 @@ export function ListPagination({
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
<ChevronLeft className="size-4 rtl:rotate-180" />
<span className="max-sm:hidden">
{t("common.pagination.previous")}
</span>
@@ -115,7 +115,7 @@ export function ListPagination({
<span className="max-sm:hidden">
{t("common.pagination.next")}
</span>
<ChevronRight className="size-4" />
<ChevronRight className="size-4 rtl:rotate-180" />
</Button>
</PaginationItem>
</PaginationContent>
+1 -1
View File
@@ -275,7 +275,7 @@ export function MenuSubTrigger({
{...props}
>
{children}
<ChevronRightIcon className="ms-auto -me-0.5 opacity-80" />
<ChevronRightIcon className="ms-auto -me-0.5 opacity-80 rtl:rotate-180" />
</MenuPrimitive.SubmenuTrigger>
);
}
+2 -2
View File
@@ -89,7 +89,7 @@ export function PaginationPrevious({
size="default"
{...props}
>
<ChevronLeftIcon className="sm:-ms-1" />
<ChevronLeftIcon className="sm:-ms-1 rtl:rotate-180" />
<span className="max-sm:hidden">Previous</span>
</PaginationLink>
);
@@ -107,7 +107,7 @@ export function PaginationNext({
{...props}
>
<span className="max-sm:hidden">Next</span>
<ChevronRightIcon className="sm:-me-1" />
<ChevronRightIcon className="sm:-me-1 rtl:rotate-180" />
</PaginationLink>
);
}
+1 -1
View File
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
export const Select: typeof SelectPrimitive.Root = SelectPrimitive.Root;
export const selectTriggerVariants = cva(
"relative inline-flex min-h-9 w-full min-w-36 select-none items-center justify-between gap-2 rounded-lg border border-input bg-background not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-left text-base text-foreground shadow-xs/5 outline-none ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
"relative inline-flex min-h-9 w-full min-w-36 select-none items-center justify-between gap-2 rounded-lg border border-input bg-background not-dark:bg-clip-padding px-[calc(--spacing(3)-1px)] text-start text-base text-foreground shadow-xs/5 outline-none ring-ring/24 transition-shadow before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--radius-lg)-1px)] not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 focus-visible:border-ring focus-visible:ring-[3px] aria-invalid:border-destructive/36 focus-visible:aria-invalid:border-destructive/64 focus-visible:aria-invalid:ring-destructive/16 data-disabled:pointer-events-none data-disabled:opacity-64 sm:min-h-8 sm:text-sm dark:bg-input/32 dark:aria-invalid:ring-destructive/24 dark:not-data-disabled:not-focus-visible:not-aria-invalid:not-data-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] [&_svg:not([class*='opacity-'])]:opacity-80 [&_svg:not([class*='size-'])]:size-4.5 sm:[&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0 [[data-disabled],:focus-visible,[aria-invalid],[data-pressed]]:shadow-none",
{
defaultVariants: {
size: "default",
+1 -1
View File
@@ -33,7 +33,7 @@ const SIDEBAR_WIDTH_ICON: string = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT: string = "b";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-lg p-2 text-start text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0",
{
defaultVariants: {
size: "default",
+2 -2
View File
@@ -40,7 +40,7 @@ export function UpdateBanner() {
};
return (
<div className="fixed right-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="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">
{t("settings.version.banner", { version: latest })}
@@ -55,7 +55,7 @@ export function UpdateBanner() {
</div>
<button
aria-label={t("settings.version.bannerDismiss")}
className="-mr-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
className="-me-1 shrink-0 rounded-lg p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={dismiss}
type="button"
>
+16 -2
View File
@@ -6,6 +6,9 @@ import { initReactI18next } from "react-i18next";
import en from "./locales/en/translation.json";
import fr from "./locales/fr/translation.json";
import so from "./locales/so/translation.json";
import ar from "./locales/ar/translation.json";
import de from "./locales/de/translation.json";
export const defaultNS = "translation";
@@ -13,10 +16,21 @@ export const defaultNS = "translation";
export const resources = {
en: { translation: en },
fr: { translation: fr },
so: { translation: so },
ar: { translation: ar },
de: { translation: de },
} as const;
// Languages offered in the Settings → Profile switcher (label rendered there).
export const supportedLanguages = ["en", "fr"] as const;
export const supportedLanguages = ["en", "fr", "so", "ar", "de"] as const;
// Right-to-left languages. Arabic is our only RTL locale today; keep this and the
// inline <head> script in app/layout.tsx (which can't import this module) in sync.
export const rtlLanguages = ["ar"] as const;
/** Writing direction for a BCP-47 language tag (e.g. "ar", "ar-SA"). */
export const dirFor = (lng: string | undefined): "rtl" | "ltr" =>
lng && rtlLanguages.some((r) => lng.startsWith(r)) ? "rtl" : "ltr";
if (!i18n.isInitialized) {
i18n
@@ -27,7 +41,7 @@ if (!i18n.isInitialized) {
defaultNS,
fallbackLng: "en",
// Keep this in sync with `resources` as languages grow.
supportedLngs: ["en", "fr"],
supportedLngs: ["en", "fr", "so", "ar", "de"],
interpolation: { escapeValue: false },
detection: {
order: ["localStorage", "navigator", "htmlTag"],
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "Record visit",
"title": "Visit scribe",
"subtitle": "Record or paste a visit for {{name}}, then review the drafted note before saving.",
"tabs": {
"record": "Record",
"paste": "Paste transcript"
},
"record": {
"start": "Start recording",
"stop": "Stop",
"ready": "Recording ready ({{duration}})",
"discard": "Discard"
},
"paste": {
"placeholder": "Paste the visit transcript here…"
},
"visitType": {
"label": "Visit type (optional)",
"placeholder": "e.g. Follow-up"
},
"consent": "The audio is stored on the patient's chart. When you use an external AI provider, the recording leaves the clinic to be transcribed — Veil cannot redact speech, so only the drafted note is de-identified.",
"generate": "Draft note",
"processing": "Working…",
"cancel": "Cancel",
"review": {
"type": "Visit type",
"date": "Date",
"summary": "Note",
"provider": "Provider: {{provider}}",
"veil": "De-identified through Veil before {{provider}}.",
"back": "Back",
"save": "Save to record"
},
"saved": {
"title": "Visit note saved"
},
"errors": {
"mic": "Couldn't access the microphone. Check browser permissions or paste a transcript instead.",
"noRecording": "Record the visit first, or switch to the paste tab.",
"empty": "The transcript is empty.",
"generic": "Something went wrong. Please try again."
}
},
"walletUpdatesList": {
"title": "Updates sent to wallets",
"description": "Record updates you've pushed to patient wallets, and whether the patient has approved them.",
"none": "You haven't pushed any wallet updates yet."
},
"walletPush": {
"action": "Push to wallet",
"title": "Push update to wallet",
"subtitle": "Send {{name}}'s updated record to their wallet app. They must approve it on their phone before it replaces the copy on their device.",
"sectionsLabel": "What changed",
"sections": {
"demographics": "Demographics",
"problems": "Problems",
"medications": "Medications",
"allergies": "Allergies",
"labs": "Labs",
"vitals": "Vitals",
"visits": "Visits"
},
"noteLabel": "Note (optional)",
"notePlaceholder": "A short note about this update…",
"notice": "This sends the full current record, signed with your clinic key. It replaces the patient's on-device copy only after they approve it.",
"cancel": "Cancel",
"send": "Send update",
"done": "Done",
"errors": {
"generic": "Couldn't send the update. Please try again."
},
"status": {
"pending": {
"title": "Waiting for the patient",
"body": "The update was sent. It will arrive on the patient's phone now, or the next time they open the app."
},
"delivered": {
"title": "Delivered to the phone",
"body": "The patient has received the update and needs to approve it on their device."
},
"approved": {
"title": "Approved by the patient",
"body": "The patient approved the update and their on-device record is now up to date."
},
"denied": {
"title": "Declined by the patient",
"body": "The patient declined this update, so their on-device record is unchanged."
}
}
},
"common": {
"appName": "temetro",
"email": "Email",
@@ -1690,6 +1781,9 @@
"label": "Display language",
"en": "English",
"fr": "Français",
"so": "Soomaali",
"ar": "العربية",
"de": "Deutsch",
"confirmTitle": "Change display language?",
"confirmBody": "Switch the interface to {{language}}? The app will reload its text in the new language.",
"confirmCta": "Change language",
@@ -1,4 +1,95 @@
{
"scribe": {
"recordVisit": "Enregistrer la visite",
"title": "Scribe de visite",
"subtitle": "Enregistrez ou collez une visite pour {{name}}, puis vérifiez la note rédigée avant de l'enregistrer.",
"tabs": {
"record": "Enregistrer",
"paste": "Coller la transcription"
},
"record": {
"start": "Démarrer l'enregistrement",
"stop": "Arrêter",
"ready": "Enregistrement prêt ({{duration}})",
"discard": "Supprimer"
},
"paste": {
"placeholder": "Collez la transcription de la visite ici…"
},
"visitType": {
"label": "Type de visite (facultatif)",
"placeholder": "ex. Suivi"
},
"consent": "L'audio est enregistré dans le dossier du patient. Si vous utilisez un fournisseur d'IA externe, l'enregistrement quitte la clinique pour être transcrit — Veil ne peut pas expurger la parole, seule la note rédigée est dépersonnalisée.",
"generate": "Rédiger la note",
"processing": "En cours…",
"cancel": "Annuler",
"review": {
"type": "Type de visite",
"date": "Date",
"summary": "Note",
"provider": "Praticien : {{provider}}",
"veil": "Dépersonnalisé via Veil avant {{provider}}.",
"back": "Retour",
"save": "Enregistrer dans le dossier"
},
"saved": {
"title": "Note de visite enregistrée"
},
"errors": {
"mic": "Impossible d'accéder au microphone. Vérifiez les autorisations du navigateur ou collez une transcription.",
"noRecording": "Enregistrez d'abord la visite, ou passez à l'onglet coller.",
"empty": "La transcription est vide.",
"generic": "Une erreur s'est produite. Veuillez réessayer."
}
},
"walletUpdatesList": {
"title": "Mises à jour envoyées aux portefeuilles",
"description": "Les mises à jour de dossiers que vous avez envoyées aux portefeuilles des patients, et si le patient les a approuvées.",
"none": "Vous n'avez encore envoyé aucune mise à jour de portefeuille."
},
"walletPush": {
"action": "Envoyer au portefeuille",
"title": "Envoyer la mise à jour au portefeuille",
"subtitle": "Envoyez le dossier mis à jour de {{name}} vers son application portefeuille. Le patient doit l'approuver sur son téléphone avant qu'elle ne remplace la copie sur son appareil.",
"sectionsLabel": "Ce qui a changé",
"sections": {
"demographics": "Données démographiques",
"problems": "Problèmes",
"medications": "Médicaments",
"allergies": "Allergies",
"labs": "Analyses",
"vitals": "Signes vitaux",
"visits": "Visites"
},
"noteLabel": "Note (facultatif)",
"notePlaceholder": "Une courte note sur cette mise à jour…",
"notice": "Ceci envoie le dossier actuel complet, signé avec la clé de votre clinique. Il ne remplace la copie sur l'appareil du patient qu'après son approbation.",
"cancel": "Annuler",
"send": "Envoyer la mise à jour",
"done": "Terminé",
"errors": {
"generic": "Impossible d'envoyer la mise à jour. Veuillez réessayer."
},
"status": {
"pending": {
"title": "En attente du patient",
"body": "La mise à jour a été envoyée. Elle arrivera sur le téléphone du patient maintenant, ou à la prochaine ouverture de l'application."
},
"delivered": {
"title": "Reçue sur le téléphone",
"body": "Le patient a reçu la mise à jour et doit l'approuver sur son appareil."
},
"approved": {
"title": "Approuvée par le patient",
"body": "Le patient a approuvé la mise à jour ; son dossier sur l'appareil est maintenant à jour."
},
"denied": {
"title": "Refusée par le patient",
"body": "Le patient a refusé cette mise à jour ; son dossier sur l'appareil est inchangé."
}
}
},
"common": {
"appName": "temetro",
"email": "E-mail",
@@ -1690,6 +1781,9 @@
"label": "Langue d'affichage",
"en": "English",
"fr": "Français",
"so": "Soomaali",
"ar": "العربية",
"de": "Deutsch",
"confirmTitle": "Changer la langue d'affichage ?",
"confirmBody": "Basculer l'interface en {{language}} ? L'application rechargera ses textes dans la nouvelle langue.",
"confirmCta": "Changer la langue",
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
import i18n, { supportedLanguages } from "@/lib/i18n/config";
import { getSettings, saveSettings } from "@/lib/settings";
// The user's chosen UI language roams across devices via the backend
// `user_settings` preferences map (localStorage stays the offline source of
// truth). We store it under this key alongside notification preferences.
const LANG_KEY = "language";
const isSupported = (value: unknown): value is string =>
typeof value === "string" &&
(supportedLanguages as readonly string[]).includes(value);
/**
* Persist the chosen language to the backend, merging into existing preferences
* so notification toggles aren't clobbered (PUT replaces the whole map).
* Best-effort: failures are swallowed since localStorage already holds the choice.
*/
export async function persistLanguage(lang: string): Promise<void> {
try {
const current = await getSettings();
if (current[LANG_KEY] === lang) return;
await saveSettings({ ...current, [LANG_KEY]: lang });
} catch {
// Ignore — the language is already applied and cached in localStorage.
}
}
/**
* On app load, adopt the language saved on the backend if it differs from the
* locally detected one (roaming to a new device). No-op when offline or when the
* stored value matches; localStorage remains authoritative otherwise.
*/
export async function applyStoredLanguage(): Promise<void> {
try {
const current = await getSettings();
const lang = current[LANG_KEY];
if (isSupported(lang) && lang !== (i18n.resolvedLanguage ?? i18n.language)) {
await i18n.changeLanguage(lang);
}
} catch {
// Ignore — unauthenticated or offline; keep the detected language.
}
}
+51
View File
@@ -0,0 +1,51 @@
// Client for the ambient AI visit-scribe API. The dialog records (or accepts a
// pasted transcript of) a clinician↔patient visit, transcribes it, drafts a
// structured encounter note, and — after the clinician reviews it — appends the
// note to the patient record (the same write-approval gate as the chat agent).
import { apiFetch } from "@/lib/api-client";
import type { Encounter, Patient } from "@/lib/patients";
export type ScribeVeil = {
active: boolean;
level: string;
classes: string[];
provider: string;
};
// Turn a stored audio attachment into a raw transcript (server streams it to the
// user's OpenAI/Gemini speech provider). Audio does NOT pass through Veil.
export function transcribeRecording(
attachmentId: string,
): Promise<{ transcript: string }> {
return apiFetch<{ transcript: string }>("/api/scribe/transcribe", {
method: "POST",
body: JSON.stringify({ attachmentId }),
});
}
// Draft an encounter note from a transcript. The transcript + patient context
// are de-identified through Veil before any external call; the draft is never
// saved automatically.
export function draftNote(input: {
fileNumber: string;
transcript: string;
visitType?: string;
date?: string;
}): Promise<{ draft: Encounter; veil: ScribeVeil }> {
return apiFetch<{ draft: Encounter; veil: ScribeVeil }>("/api/scribe/draft", {
method: "POST",
body: JSON.stringify(input),
});
}
// The approval step: append the reviewed note to the patient record.
export function saveNote(
fileNumber: string,
encounter: Encounter,
): Promise<Patient> {
return apiFetch<Patient>("/api/scribe/save", {
method: "POST",
body: JSON.stringify({ fileNumber, encounter }),
});
}
+47
View File
@@ -0,0 +1,47 @@
// Client for the clinic→wallet record-update push. When a clinician edits a
// wallet-linked patient, they can push the updated record to the patient's app;
// it stays pending until the patient approves it on their phone.
import { apiFetch } from "@/lib/api-client";
export type WalletUpdateStatus = "pending" | "delivered" | "approved" | "denied";
export type WalletUpdate = {
id: string;
fileNumber: string;
walletNumber: string;
status: WalletUpdateStatus;
changes: string[];
createdAt: string;
deliveredAt: string | null;
resolvedAt: string | null;
};
// Resolve the wallet a patient is linked to. Rejects (404) when not wallet-backed.
export function getWalletLink(
fileNumber: string,
): Promise<{ walletNumber: string }> {
return apiFetch<{ walletNumber: string }>(
`/api/patients/wallet/link/${encodeURIComponent(fileNumber)}`,
);
}
export function pushWalletUpdate(input: {
fileNumber: string;
changes: string[];
}): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>("/api/patients/wallet/push", {
method: "POST",
body: JSON.stringify(input),
});
}
export function listWalletUpdates(): Promise<WalletUpdate[]> {
return apiFetch<WalletUpdate[]>("/api/patients/wallet/updates");
}
export function getWalletUpdate(id: string): Promise<WalletUpdate> {
return apiFetch<WalletUpdate>(
`/api/patients/wallet/updates/${encodeURIComponent(id)}`,
);
}
+3 -2
View File
@@ -1,12 +1,13 @@
{
"name": "frontend",
"version": "0.2.5",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"check-locales": "node scripts/check-locales.mjs"
},
"dependencies": {
"@ai-sdk/react": "^3.0.206",
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Locale parity check: every locale under lib/i18n/locales must have the same set
// of logical keys as English, with matching {{interpolation}} placeholders.
//
// "Logical key" collapses i18next plural suffixes: `foo`, `foo_one`, `foo_other`,
// `foo_zero`, `foo_two`, `foo_few`, `foo_many` all map to the logical key `foo`.
// This lets Arabic carry six CLDR plural forms where English has two, without the
// check flagging a mismatch.
//
// Exit code is non-zero if any locale is missing/extra keys or has a placeholder
// mismatch. Arabic count-keys missing the full six CLDR forms are warnings only.
import { readFileSync, readdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const localesDir = join(here, "..", "lib", "i18n", "locales");
const REFERENCE = "en";
const PLURAL_SUFFIXES = ["zero", "one", "two", "few", "many", "other"];
const AR_REQUIRED = ["zero", "one", "two", "few", "many", "other"];
/** Flatten a nested object into dotted paths → string values. */
function flatten(obj, prefix = "", out = {}) {
for (const [key, value] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
flatten(value, path, out);
} else {
out[path] = value;
}
}
return out;
}
/** Strip a trailing i18next plural suffix, returning [logicalKey, suffix|null]. */
function splitPlural(path) {
for (const suffix of PLURAL_SUFFIXES) {
if (path.endsWith(`_${suffix}`)) {
return [path.slice(0, -(suffix.length + 1)), suffix];
}
}
return [path, null];
}
/** All {{placeholder}} names in a string, sorted and de-duplicated. */
function placeholders(value) {
if (typeof value !== "string") return [];
const found = new Set();
for (const match of value.matchAll(/\{\{\s*([\w.-]+)\s*\}\}/g)) {
found.add(match[1]);
}
return [...found].sort();
}
function logicalKeys(flat) {
// logicalKey → { suffixes: Set, placeholders: string[] }
const map = new Map();
for (const [path, value] of Object.entries(flat)) {
const [logical, suffix] = splitPlural(path);
const entry = map.get(logical) ?? { suffixes: new Set(), placeholders: [] };
if (suffix) entry.suffixes.add(suffix);
// Placeholders should be identical across plural forms; take the richest set.
const ph = placeholders(value);
if (ph.length > entry.placeholders.length) entry.placeholders = ph;
map.set(logical, entry);
}
return map;
}
const load = (lng) =>
JSON.parse(readFileSync(join(localesDir, lng, "translation.json"), "utf8"));
const reference = logicalKeys(flatten(load(REFERENCE)));
const locales = readdirSync(localesDir, { withFileTypes: true })
.filter((d) => d.isDirectory() && d.name !== REFERENCE)
.map((d) => d.name);
let hadError = false;
for (const lng of locales) {
const flat = flatten(load(lng));
const keys = logicalKeys(flat);
const errors = [];
const warnings = [];
for (const [key, ref] of reference) {
if (!keys.has(key)) {
errors.push(`missing key: ${key}`);
continue;
}
const got = keys.get(key);
const refPh = ref.placeholders.join(",");
const gotPh = got.placeholders.join(",");
if (refPh !== gotPh) {
errors.push(`placeholder mismatch at ${key}: en[${refPh}] vs ${lng}[${gotPh}]`);
}
if (lng === "ar" && ref.suffixes.size > 0) {
const missing = AR_REQUIRED.filter((s) => !got.suffixes.has(s));
if (missing.length) {
warnings.push(`ar plural ${key} missing CLDR forms: ${missing.join(", ")}`);
}
}
}
for (const key of keys.keys()) {
if (!reference.has(key)) errors.push(`extra key not in en: ${key}`);
}
if (errors.length) {
hadError = true;
console.error(`\n${lng}: ${errors.length} error(s)`);
for (const e of errors) console.error(` - ${e}`);
} else {
console.log(`${lng}: parity OK (${reference.size} keys)`);
}
for (const w of warnings) console.warn(`${w}`);
}
process.exit(hadError ? 1 : 0);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "temetro",
"version": "0.2.5",
"version": "0.5.0",
"private": true,
"devDependencies": {
"shadcn": "^4.11.0"