i18n: convert settings, chat, patient records and notes; update docs

Finish the i18n pass: settings panels (profile/care-team/signing), the
chat heading + input, the patient cards / detail / create-edit form, and
the notes page + rich-text editor are all keyed in en/translation.json.
All 526 static t() keys resolve. Document the new backend resources +
Socket.io realtime (backend README/CLAUDE) and the i18n coverage
(frontend CLAUDE).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-08 02:23:20 +03:00
parent e0de20b551
commit ab2f10bffc
17 changed files with 850 additions and 364 deletions
+8
View File
@@ -38,6 +38,14 @@ No test runner is configured. Verify by running the stack (`docker compose up`)
(`src/types/patient.ts`, mirrors `../frontend/lib/patients.ts`). **`src/routes/patients.ts`** is
org-scoped CRUD, gated by **`src/middleware/auth.ts`** (`requireAuth``requireOrg`
`requirePermission`).
- **Other resources** follow the patients/notes pattern (schema → validation → types → service →
org-scoped route): **appointments**, **prescriptions**, **tasks** (RBAC-gated like patients),
plus **activity** (an audit log written best-effort from every resource route via
`services/activity.ts`), **analytics** (computed aggregates), **messaging** (conversations /
participants / messages) and **notifications** (per-recipient, auto-generated).
- **Real-time** lives in **`src/realtime.ts`** — a Socket.io server attached to the same HTTP server
in `index.ts`; the handshake reuses Better Auth's `getSession`. Other modules push via
`emitToUser` / `emitToConversation` (no direct socket import, so no circular deps).
- **`src/lib/email.ts`** — `sendEmail` logs links to the console when SMTP is unset.
## Gotchas / conventions
+16
View File
@@ -57,6 +57,22 @@ organization; access is gated by the caller's clinic role.
| PUT | `/api/patients/:fileNumber` | `patient:write` | replace the full record |
| DELETE | `/api/patients/:fileNumber` | `patient:delete` | remove a patient |
Other org-scoped resources follow the same pattern (CRUD, role-gated):
| Resource | Base path | Permission | Notes |
| --- | --- | --- | --- |
| Appointments | `/api/appointments` | `appointment:*` | list / create / update / delete |
| Prescriptions | `/api/prescriptions` | `prescription:*` | prescriber defaults to the signed-in user |
| Tasks | `/api/tasks` | `task:*` | `PATCH /:id` for partial updates / the done toggle |
| Notes | `/api/notes` | — (author-scoped) | private to the signed-in author |
| Activity | `GET /api/activity` | — (any member) | audit feed of record changes |
| Analytics | `GET /api/analytics` | — (any member) | computed clinic aggregates |
| Conversations | `/api/conversations` | — (participant-scoped) | staff messaging; real-time over Socket.io |
| Notifications | `/api/notifications` | — (per-recipient) | auto-generated; `read-all` + per-id read |
Real-time messaging and live notifications are delivered over **Socket.io**, attached to the same
HTTP server; the handshake is authenticated with the Better Auth session cookie.
Auth endpoints (sign up / in / out, verify email, reset password, organizations & invitations) are
served by Better Auth under `/api/auth/*`.
+8 -2
View File
@@ -108,8 +108,14 @@ white/6%), so layered surfaces stay close in lightness.
`lib/i18n/locales/en/translation.json`). `components/i18n-provider.tsx` wraps the app in
`app/layout.tsx`. Use `const { t } = useTranslation()` + nested keys (e.g. `t("auth.login.title")`)
in **client** components. To add a language, drop a `locales/<lng>/translation.json` and register it
in `resources`/`supportedLngs` in `config.ts`. Auth forms, sidebar nav, and settings tabs are
converted as the reference pattern; other strings can be migrated incrementally.
in `resources`/`supportedLngs` in `config.ts`.
**Coverage:** essentially all user-facing strings are now keyed (every app page + its dialogs/sheets,
auth pages, settings panels, the sidebar/user menu, chat input, patient cards/detail/form, messages,
notifications, notes). Keys are grouped by feature (`appointments.*`, `patientCard.*`, `messages.*`,
…). When adding UI, add a key rather than a literal. The only intentional literals left are clinical
**option values** stored as data (appointment types, dose frequencies/durations) and proper nouns
(e.g. `Ed25519`). There is no language switcher yet (English only); locale is auto-detected.
## Gotchas
+64 -42
View File
@@ -19,9 +19,11 @@ import {
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import {
@@ -40,34 +42,35 @@ type ChatInputProps = {
};
type Option = { value: string; label: string };
type OptionKey = { value: string; labelKey: string };
const ACCESS_OPTIONS: Option[] = [
{ value: "standard", label: "Standard access" },
{ value: "break-glass", label: "Break-glass (emergency)" },
{ value: "read-only", label: "Read-only" },
const ACCESS_OPTIONS: OptionKey[] = [
{ value: "standard", labelKey: "chat.input.access.standard" },
{ value: "break-glass", labelKey: "chat.input.access.breakGlass" },
{ value: "read-only", labelKey: "chat.input.access.readOnly" },
];
const RESPONSE_OPTIONS: Option[] = [
{ value: "concise", label: "Concise" },
{ value: "detailed", label: "Detailed" },
{ value: "comprehensive", label: "Comprehensive" },
const RESPONSE_OPTIONS: OptionKey[] = [
{ value: "concise", labelKey: "chat.input.response.concise" },
{ value: "detailed", labelKey: "chat.input.response.detailed" },
{ value: "comprehensive", labelKey: "chat.input.response.comprehensive" },
];
const SPECIALTY_OPTIONS: Option[] = [
{ value: "internal-medicine", label: "Internal Medicine" },
{ value: "cardiology", label: "Cardiology" },
{ value: "pediatrics", label: "Pediatrics" },
{ value: "emergency", label: "Emergency" },
{ value: "all", label: "All specialties" },
const SPECIALTY_OPTIONS: OptionKey[] = [
{ value: "internal-medicine", labelKey: "chat.input.specialtyOptions.internalMedicine" },
{ value: "cardiology", labelKey: "chat.input.specialtyOptions.cardiology" },
{ value: "pediatrics", labelKey: "chat.input.specialtyOptions.pediatrics" },
{ value: "emergency", labelKey: "chat.input.specialtyOptions.emergency" },
{ value: "all", labelKey: "chat.input.specialtyOptions.all" },
];
const FACILITY_OPTIONS: Option[] = [
{ value: "main-hospital", label: "Main Hospital" },
{ value: "north-clinic", label: "North Clinic" },
{ value: "telehealth", label: "Telehealth" },
const FACILITY_OPTIONS: OptionKey[] = [
{ value: "main-hospital", labelKey: "chat.input.facilityOptions.mainHospital" },
{ value: "north-clinic", labelKey: "chat.input.facilityOptions.northClinic" },
{ value: "telehealth", labelKey: "chat.input.facilityOptions.telehealth" },
];
const TIME_OPTIONS: Option[] = [
{ value: "30d", label: "Last 30 days" },
{ value: "12m", label: "Last 12 months" },
{ value: "5y", label: "Last 5 years" },
{ value: "all", label: "All time" },
const TIME_OPTIONS: OptionKey[] = [
{ value: "30d", labelKey: "chat.input.timeOptions.30d" },
{ value: "12m", labelKey: "chat.input.timeOptions.12m" },
{ value: "5y", labelKey: "chat.input.timeOptions.5y" },
{ value: "all", labelKey: "chat.input.timeOptions.all" },
];
const iconButton =
@@ -128,6 +131,21 @@ function SelectPill({
}
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
const { t } = useTranslation();
const toOptions = useCallback(
(opts: OptionKey[]): Option[] =>
opts.map((o) => ({ value: o.value, label: t(o.labelKey) })),
[t],
);
const accessOptions = useMemo(() => toOptions(ACCESS_OPTIONS), [toOptions]);
const responseOptions = useMemo(() => toOptions(RESPONSE_OPTIONS), [toOptions]);
const specialtyOptions = useMemo(
() => toOptions(SPECIALTY_OPTIONS),
[toOptions],
);
const facilityOptions = useMemo(() => toOptions(FACILITY_OPTIONS), [toOptions]);
const timeOptions = useMemo(() => toOptions(TIME_OPTIONS), [toOptions]);
const [value, setValue] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [access, setAccess] = useState("standard");
@@ -203,11 +221,11 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
<div className="rounded-b-[22px] bg-input">
<textarea
aria-label="Message"
aria-label={t("chat.input.message")}
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Look up a patient — try /patient 10293"
placeholder={t("chat.input.placeholder")}
rows={1}
value={value}
/>
@@ -221,7 +239,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
>
<span className="max-w-40 truncate">{file.name}</span>
<button
aria-label={`Remove ${file.name}`}
aria-label={t("chat.input.removeFile", { name: file.name })}
className="text-muted-foreground transition-colors hover:text-foreground"
onClick={() => removeFile(index)}
type="button"
@@ -234,7 +252,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
)}
<input
aria-label="Attach files"
aria-label={t("chat.input.attachFiles")}
className="hidden"
multiple
onChange={handleFilesSelected}
@@ -245,7 +263,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
<div className="flex items-center justify-between gap-2 px-3 pb-3">
<div className="flex min-w-0 items-center gap-1">
<button
aria-label="Attach file"
aria-label={t("chat.input.attachFile")}
className={iconButton}
onClick={() => fileInputRef.current?.click()}
type="button"
@@ -253,11 +271,11 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
<Plus className="size-[18px]" />
</button>
<SelectPill
ariaLabel="Access level"
ariaLabel={t("chat.input.accessLevel")}
chevronClassName="size-4 opacity-70"
icon={<Hand className="size-4" />}
onValueChange={setAccess}
options={ACCESS_OPTIONS}
options={accessOptions}
triggerClassName={pillButton}
value={access}
/>
@@ -266,20 +284,24 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
<div className="flex shrink-0 items-center gap-1">
<SelectPill
align="end"
ariaLabel="Response mode"
ariaLabel={t("chat.input.responseMode")}
chevronClassName="size-4 opacity-70"
icon={null}
onValueChange={setResponseMode}
options={RESPONSE_OPTIONS}
prefix="Clinical"
options={responseOptions}
prefix={t("chat.input.clinical")}
triggerClassName={cn(pillButton, "mr-1")}
value={responseMode}
/>
<button aria-label="Dictate" className={iconButton} type="button">
<button
aria-label={t("chat.input.dictate")}
className={iconButton}
type="button"
>
<Mic className="size-[18px]" />
</button>
<button
aria-label={isGenerating ? "Stop" : "Send"}
aria-label={isGenerating ? t("chat.input.stop") : t("chat.input.send")}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
canSend || isGenerating
@@ -303,29 +325,29 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
<SelectPill
ariaLabel="Specialty"
ariaLabel={t("chat.input.specialty")}
chevronClassName="size-3.5 opacity-70"
icon={<Stethoscope className="size-4" />}
onValueChange={setSpecialty}
options={SPECIALTY_OPTIONS}
options={specialtyOptions}
triggerClassName={contextPill}
value={specialty}
/>
<SelectPill
ariaLabel="Facility"
ariaLabel={t("chat.input.facility")}
chevronClassName="size-3.5 opacity-70"
icon={<Building2 className="size-4" />}
onValueChange={setFacility}
options={FACILITY_OPTIONS}
options={facilityOptions}
triggerClassName={contextPill}
value={facility}
/>
<SelectPill
ariaLabel="Time range"
ariaLabel={t("chat.input.timeRange")}
chevronClassName="size-3.5 opacity-70"
icon={<CalendarRange className="size-4" />}
onValueChange={setTimeRange}
options={TIME_OPTIONS}
options={timeOptions}
triggerClassName={contextPill}
value={timeRange}
/>
@@ -338,7 +360,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
type="button"
>
<UserPlus className="size-4" />
<span>Add patient</span>
<span>{t("chat.input.addPatient")}</span>
</button>
</div>
</form>
+3 -3
View File
@@ -4,6 +4,7 @@ import { nanoid } from "nanoid";
import type { ChatStatus } from "ai";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Conversation,
@@ -31,12 +32,11 @@ type ChatMessage =
patient?: Patient;
};
const HEADING = "Which patient would you like to look up?";
// Trigger: `/patient 10293` or just `/10293` pulls up records.
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
export function ChatPanel() {
const { t } = useTranslation();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>("ready");
@@ -135,7 +135,7 @@ export function ChatPanel() {
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{HEADING}
{t("chat.heading")}
</h1>
{promptInput}
</div>
+121 -73
View File
@@ -2,6 +2,7 @@
import { Pencil } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -59,8 +60,6 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
discharged: "outline",
};
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
// plus a subtle clickable affordance (they open a detail dialog).
const rowCard =
@@ -102,13 +101,19 @@ function Empty({ children }: { children: ReactNode }) {
}
function TrendBlock({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
return <Empty>{t("patientCard.trend.empty")}</Empty>;
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length}`}</SectionLabel>
<SectionLabel>
{t("patientCard.trend.last", {
label: trend.label,
count: trend.points.length,
})}
</SectionLabel>
<span className="text-foreground">
{trend.points.at(-1)}
<span className="text-muted-foreground"> {trend.unit}</span>
@@ -120,27 +125,35 @@ function TrendBlock({ trend }: { trend: Trend }) {
}
function TrendDetail({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
return <Empty>{t("patientCard.trend.empty")}</Empty>;
}
const min = Math.min(...trend.points);
const max = Math.max(...trend.points);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length} readings`}</SectionLabel>
<SectionLabel>
{t("patientCard.trend.lastReadings", {
label: trend.label,
count: trend.points.length,
})}
</SectionLabel>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>
Latest{" "}
{t("patientCard.trend.latest")}{" "}
<span className="text-foreground">
{trend.points.at(-1)} {trend.unit}
</span>
</span>
<span>
Min <span className="text-foreground">{min}</span>
{t("patientCard.trend.min")}{" "}
<span className="text-foreground">{min}</span>
</span>
<span>
Max <span className="text-foreground">{max}</span>
{t("patientCard.trend.max")}{" "}
<span className="text-foreground">{max}</span>
</span>
</div>
</div>
@@ -204,26 +217,29 @@ function SummaryCard({
patient: Patient;
onEdit?: () => void;
}) {
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
const { t } = useTranslation();
const sex = t(`patientCard.sex.${patient.sex}`);
const statusLabel = t(`patients.status.${patient.status}`);
const idLine = `${patient.age} · ${sex} · MRN ${patient.fileNumber}`;
return (
<ExpandableCard
description={idLine}
detail={
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Stat label="Full name" value={patient.name} />
<Stat label="MRN" value={patient.fileNumber} />
<Stat label="Age" value={patient.age} />
<Stat label="Sex" value={sexLabel[patient.sex]} />
<Stat label="Primary care" value={patient.pcp} />
<Stat label={t("patientCard.summary.fullName")} value={patient.name} />
<Stat label={t("patientCard.summary.mrn")} value={patient.fileNumber} />
<Stat label={t("patientCard.summary.age")} value={patient.age} />
<Stat label={t("patientCard.summary.sex")} value={sex} />
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
<Stat label={t("patientCard.summary.status")} value={statusLabel} />
<Stat
label="Status"
value={<span className="capitalize">{patient.status}</span>}
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat
label="Allergies"
value={patient.allergies.length || "None"}
label={t("patientCard.summary.allergies")}
value={patient.allergies.length || t("patientCard.summary.none")}
/>
</div>
<AlertBadges alerts={patient.alerts} />
@@ -240,17 +256,24 @@ function SummaryCard({
<CardTitle>{patient.name}</CardTitle>
<CardDescription>{idLine}</CardDescription>
</div>
<Badge className="capitalize" variant={statusVariant[patient.status]}>
{patient.status}
</Badge>
<Badge variant={statusVariant[patient.status]}>{statusLabel}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Stat label="Primary care" value={patient.pcp} />
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat label="Active meds" value={patient.medications.length} />
<Stat label="Open problems" value={patient.problems.length} />
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
<Stat
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</div>
<AlertBadges alerts={patient.alerts} />
<button
@@ -262,7 +285,7 @@ function SummaryCard({
type="button"
>
<Pencil className="size-4" />
Edit record
{t("patientCard.summary.editRecord")}
</button>
</CardContent>
</ExpandableCard>
@@ -270,12 +293,13 @@ function SummaryCard({
}
function VitalsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
const { vitals } = patient;
const vitalItems = [
{ label: "BP", value: vitals.bp },
{ label: "HR", value: vitals.hr },
{ label: "Temp", value: vitals.temp },
{ label: "SpO₂", value: vitals.spo2 },
{ label: t("patientCard.vitals.bp"), value: vitals.bp },
{ label: t("patientCard.vitals.hr"), value: vitals.hr },
{ label: t("patientCard.vitals.temp"), value: vitals.temp },
{ label: t("patientCard.vitals.spo2"), value: vitals.spo2 },
];
const vitalsGrid = (gapY: string) => (
<div className={cn("grid grid-cols-2 gap-x-4", gapY)}>
@@ -287,7 +311,7 @@ function VitalsCard({ patient }: { patient: Patient }) {
return (
<ExpandableCard
description={`Taken ${vitals.takenAt}`}
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
detail={
<div className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
@@ -295,11 +319,13 @@ function VitalsCard({ patient }: { patient: Patient }) {
<TrendDetail trend={patient.vitalsTrend} />
</div>
}
title="Vitals"
title={t("patientCard.vitals.title")}
>
<CardHeader>
<CardTitle>Vitals</CardTitle>
<CardDescription>Taken {vitals.takenAt}</CardDescription>
<CardTitle>{t("patientCard.vitals.title")}</CardTitle>
<CardDescription>
{t("patientCard.vitals.taken", { at: vitals.takenAt })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
@@ -310,24 +336,26 @@ function VitalsCard({ patient }: { patient: Patient }) {
);
}
function labValue(value: string, flag: LabFlag) {
function LabValue({ value, flag }: { value: string; flag: LabFlag }) {
const { t } = useTranslation();
return (
<span className="flex items-center gap-2">
{value}
<Badge className="capitalize" variant={labFlagVariant[flag]}>
{flag}
</Badge>
<Badge variant={labFlagVariant[flag]}>{t(`patientCard.labFlag.${flag}`)}</Badge>
</span>
);
}
function LabsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<ExpandableCard
description={`As of ${patient.labs[0]?.takenAt ?? "—"}`}
description={t("patientCard.labs.asOf", {
at: patient.labs[0]?.takenAt ?? "—",
})}
detail={
patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
<Empty>{t("patientCard.labs.empty")}</Empty>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5">
@@ -342,7 +370,7 @@ function LabsCard({ patient }: { patient: Patient }) {
{lab.takenAt}
</span>
</div>
{labValue(lab.value, lab.flag)}
<LabValue flag={lab.flag} value={lab.value} />
</div>
))}
</div>
@@ -351,15 +379,17 @@ function LabsCard({ patient }: { patient: Patient }) {
</div>
)
}
title="Labs"
title={t("patientCard.labs.title")}
>
<CardHeader>
<CardTitle>Labs</CardTitle>
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
<CardTitle>{t("patientCard.labs.title")}</CardTitle>
<CardDescription>
{t("patientCard.labs.asOf", { at: patient.labs[0]?.takenAt ?? "—" })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
<Empty>{t("patientCard.labs.empty")}</Empty>
) : (
<>
<div className="flex flex-col gap-2">
@@ -367,7 +397,7 @@ function LabsCard({ patient }: { patient: Patient }) {
<Row
key={lab.name}
label={lab.name}
value={labValue(lab.value, lab.flag)}
value={<LabValue flag={lab.flag} value={lab.value} />}
/>
))}
</div>
@@ -381,9 +411,10 @@ function LabsCard({ patient }: { patient: Patient }) {
}
function MedicationsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
const list =
patient.medications.length === 0 ? (
<Empty>No active medications.</Empty>
<Empty>{t("patientCard.medications.empty")}</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
@@ -397,13 +428,19 @@ function MedicationsCard({ patient }: { patient: Patient }) {
);
return (
<ExpandableCard
description={`${patient.medications.length} active`}
description={t("patientCard.medications.active", {
count: patient.medications.length,
})}
detail={list}
title="Medications"
title={t("patientCard.medications.title")}
>
<CardHeader>
<CardTitle>Medications</CardTitle>
<CardDescription>{patient.medications.length} active</CardDescription>
<CardTitle>{t("patientCard.medications.title")}</CardTitle>
<CardDescription>
{t("patientCard.medications.active", {
count: patient.medications.length,
})}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
@@ -411,29 +448,34 @@ function MedicationsCard({ patient }: { patient: Patient }) {
}
function ProblemsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
const list =
patient.problems.length === 0 ? (
<Empty>No active problems.</Empty>
<Empty>{t("patientCard.problems.empty")}</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
value={t("patientCard.problems.since", { date: problem.since })}
/>
))}
</div>
);
return (
<ExpandableCard
description={`${patient.problems.length} active`}
description={t("patientCard.problems.active", {
count: patient.problems.length,
})}
detail={list}
title="Problems"
title={t("patientCard.problems.title")}
>
<CardHeader>
<CardTitle>Problems</CardTitle>
<CardDescription>{patient.problems.length} active</CardDescription>
<CardTitle>{t("patientCard.problems.title")}</CardTitle>
<CardDescription>
{t("patientCard.problems.active", { count: patient.problems.length })}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
@@ -441,13 +483,16 @@ function ProblemsCard({ patient }: { patient: Patient }) {
}
function AllergiesList({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<AlertBadges alerts={patient.alerts} />
<div className="flex flex-col gap-2">
<SectionLabel>Allergies</SectionLabel>
<SectionLabel>{t("patientCard.allergies.sectionLabel")}</SectionLabel>
{patient.allergies.length === 0 ? (
<p className="text-muted-foreground">No known allergies.</p>
<p className="text-muted-foreground">
{t("patientCard.allergies.none")}
</p>
) : (
patient.allergies.map((allergy) => (
<Row
@@ -462,11 +507,8 @@ function AllergiesList({ patient }: { patient: Patient }) {
</>
}
value={
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
<Badge variant={severityVariant[allergy.severity]}>
{t(`patientCard.severity.${allergy.severity}`)}
</Badge>
}
/>
@@ -478,13 +520,14 @@ function AllergiesList({ patient }: { patient: Patient }) {
}
function AllergiesCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<ExpandableCard
detail={<AllergiesList patient={patient} />}
title="Allergies & alerts"
title={t("patientCard.allergies.title")}
>
<CardHeader>
<CardTitle>Allergies & alerts</CardTitle>
<CardTitle>{t("patientCard.allergies.title")}</CardTitle>
</CardHeader>
<CardContent>
<AllergiesList patient={patient} />
@@ -494,8 +537,9 @@ function AllergiesCard({ patient }: { patient: Patient }) {
}
function VisitsList({ patient }: { patient: Patient }) {
const { t } = useTranslation();
if (patient.encounters.length === 0) {
return <Empty>No visits yet.</Empty>;
return <Empty>{t("patientCard.visits.empty")}</Empty>;
}
return (
<div className="flex flex-col gap-3">
@@ -521,14 +565,17 @@ function VisitsList({ patient }: { patient: Patient }) {
}
function VisitsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<ExpandableCard
description={`${patient.encounters.length} recent`}
description={t("patientCard.visits.recent", {
count: patient.encounters.length,
})}
detail={<VisitsList patient={patient} />}
title="Recent visits"
title={t("patientCard.visits.title")}
>
<CardHeader>
<CardTitle>Recent visits</CardTitle>
<CardTitle>{t("patientCard.visits.title")}</CardTitle>
</CardHeader>
<CardContent>
<VisitsList patient={patient} />
@@ -580,6 +627,7 @@ export function PatientResult({
onPatientUpdated,
layout = "row",
}: PatientResultProps) {
const { t } = useTranslation();
const [editOpen, setEditOpen] = useState(false);
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
@@ -589,7 +637,7 @@ export function PatientResult({
<Card className={compactCard}>
<CardContent>
<p className="text-muted-foreground">
No patient found for file #{fileNumber}.
{t("patientCard.notFound", { number: fileNumber })}
</p>
</CardContent>
</Card>
+110 -76
View File
@@ -2,6 +2,7 @@
import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -72,6 +73,7 @@ function SectionList<T>({
onChange: (rows: T[]) => void;
render: (row: T, set: (patch: Partial<T>) => void) => ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@@ -85,7 +87,7 @@ function SectionList<T>({
variant="ghost"
>
<Plus className="size-4" />
Add
{t("patientForm.add")}
</Button>
</div>
{rows.map((row, index) => (
@@ -94,7 +96,7 @@ function SectionList<T>({
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
)}
<button
aria-label={`Remove ${label} row`}
aria-label={t("patientForm.removeRow", { label })}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => onChange(rows.filter((_, i) => i !== index))}
type="button"
@@ -145,7 +147,7 @@ function DatePicker({
value,
onChange,
ariaLabel,
placeholder = "Pick a date",
placeholder,
className,
}: {
value: string;
@@ -154,7 +156,9 @@ function DatePicker({
placeholder?: string;
className?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const placeholderText = placeholder ?? t("patientForm.pickDate");
return (
<Popover onOpenChange={setOpen} open={open}>
@@ -173,7 +177,7 @@ function DatePicker({
}
>
<CalendarIcon className="size-4" />
<span className="truncate">{value || placeholder}</span>
<span className="truncate">{value || placeholderText}</span>
</PopoverTrigger>
<PopoverPopup className="w-auto p-0">
<Calendar
@@ -197,6 +201,7 @@ export function PatientFormDialog({
onCreated,
onSaved,
}: PatientFormDialogProps) {
const { t } = useTranslation();
const isEdit = mode === "edit";
const [submitting, setSubmitting] = useState(false);
@@ -290,17 +295,26 @@ export function PatientFormDialog({
: await createPatient(built);
if (isEdit) {
onSaved?.(saved);
notify.success("Record updated", `${saved.name}'s chart was saved.`);
notify.success(
t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }),
);
} else {
onCreated?.(saved.fileNumber);
notify.success("Patient added", `${saved.name} (${saved.fileNumber}).`);
notify.success(
t("patientForm.addedTitle"),
t("patientForm.addedBody", {
name: saved.name,
fileNumber: saved.fileNumber,
}),
);
}
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : "Could not save the patient.";
err instanceof Error ? err.message : t("patientForm.saveError");
setError(message);
notify.error("Couldn't save patient", message);
notify.error(t("patientForm.saveFailedTitle"), message);
} finally {
setSubmitting(false);
}
@@ -310,11 +324,15 @@ export function PatientFormDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit record" : "Add patient"}</DialogTitle>
<DialogTitle>
{isEdit ? t("patientForm.editTitle") : t("patientForm.createTitle")}
</DialogTitle>
<DialogDescription>
{isEdit
? `Update ${patient?.name ?? "this"}'s chart and add new data.`
: "Create a new chart. A file number has been generated for you."}
? t("patientForm.editDescription", {
name: patient?.name ?? "this",
})
: t("patientForm.createDescription")}
</DialogDescription>
</DialogHeader>
@@ -323,12 +341,12 @@ export function PatientFormDialog({
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
<Field label="File number">
<Field label={t("patientForm.fileNumber")}>
<div className="flex items-center gap-2">
<Input readOnly value={fileNumber} />
{!isEdit && (
<Button
aria-label="Regenerate file number"
aria-label={t("patientForm.regenerate")}
onClick={() => setFileNumber(generateFileNumber())}
size="icon"
type="button"
@@ -340,18 +358,18 @@ export function PatientFormDialog({
</div>
</Field>
<Field label="Full name">
<Field label={t("patientForm.fullName")}>
<Input
autoFocus={!isEdit}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Jordan Pierce"
placeholder={t("patientForm.fullNamePlaceholder")}
required
value={name}
/>
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="Age">
<Field label={t("patientForm.age")}>
<Input
inputMode="numeric"
onChange={(event) => setAge(event.target.value)}
@@ -359,7 +377,7 @@ export function PatientFormDialog({
value={age}
/>
</Field>
<Field label="Sex">
<Field label={t("patientForm.sex")}>
<select
className={controlClass}
onChange={(event) =>
@@ -367,11 +385,11 @@ export function PatientFormDialog({
}
value={sex}
>
<option value="F">Female</option>
<option value="M">Male</option>
<option value="F">{t("patientCard.sex.F")}</option>
<option value="M">{t("patientCard.sex.M")}</option>
</select>
</Field>
<Field label="Status">
<Field label={t("patientForm.status")}>
<select
className={controlClass}
onChange={(event) =>
@@ -379,48 +397,52 @@ export function PatientFormDialog({
}
value={status}
>
<option value="active">Active</option>
<option value="inpatient">Inpatient</option>
<option value="discharged">Discharged</option>
<option value="active">{t("patients.status.active")}</option>
<option value="inpatient">
{t("patients.status.inpatient")}
</option>
<option value="discharged">
{t("patients.status.discharged")}
</option>
</select>
</Field>
</div>
<Field label="Primary care">
<Field label={t("patientForm.primaryCare")}>
<Input
onChange={(event) => setPcp(event.target.value)}
placeholder="e.g. Dr. Lena Ortiz"
placeholder={t("patientForm.primaryCarePlaceholder")}
value={pcp}
/>
</Field>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Current vitals
{t("patientForm.currentVitals")}
</span>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Input
aria-label="Blood pressure"
aria-label={t("patientForm.bp")}
onChange={(event) => setBp(event.target.value)}
placeholder="BP"
placeholder={t("patientCard.vitals.bp")}
value={bp}
/>
<Input
aria-label="Heart rate"
aria-label={t("patientForm.hr")}
onChange={(event) => setHr(event.target.value)}
placeholder="HR"
placeholder={t("patientCard.vitals.hr")}
value={hr}
/>
<Input
aria-label="Temperature"
aria-label={t("patientForm.temp")}
onChange={(event) => setTemp(event.target.value)}
placeholder="Temp"
placeholder={t("patientCard.vitals.temp")}
value={temp}
/>
<Input
aria-label="Oxygen saturation"
aria-label={t("patientForm.spo2")}
onChange={(event) => setSpo2(event.target.value)}
placeholder="SpO₂"
placeholder={t("patientCard.vitals.spo2")}
value={spo2}
/>
</div>
@@ -428,33 +450,37 @@ export function PatientFormDialog({
<SectionList<AllergyDraft>
blank={{ substance: "", reaction: "", severity: "mild" }}
label="Allergies"
label={t("patientForm.allergies")}
onChange={setAllergies}
render={(row, set) => (
<>
<Input
aria-label="Substance"
aria-label={t("patientForm.substance")}
onChange={(event) => set({ substance: event.target.value })}
placeholder="Substance"
placeholder={t("patientForm.substance")}
value={row.substance}
/>
<Input
aria-label="Reaction"
aria-label={t("patientForm.reaction")}
onChange={(event) => set({ reaction: event.target.value })}
placeholder="Reaction"
placeholder={t("patientForm.reaction")}
value={row.reaction}
/>
<select
aria-label="Severity"
aria-label={t("patientForm.severityAria")}
className={cn(controlClass, "w-auto")}
onChange={(event) =>
set({ severity: event.target.value as AllergySeverity })
}
value={row.severity}
>
<option value="mild">Mild</option>
<option value="moderate">Moderate</option>
<option value="severe">Severe</option>
<option value="mild">{t("patientCard.severity.mild")}</option>
<option value="moderate">
{t("patientCard.severity.moderate")}
</option>
<option value="severe">
{t("patientCard.severity.severe")}
</option>
</select>
</>
)}
@@ -463,26 +489,26 @@ export function PatientFormDialog({
<SectionList<MedicationDraft>
blank={{ name: "", dose: "", frequency: "" }}
label="Medications"
label={t("patientForm.medications")}
onChange={setMedications}
render={(row, set) => (
<>
<Input
aria-label="Medication name"
aria-label={t("patientForm.medNameAria")}
onChange={(event) => set({ name: event.target.value })}
placeholder="Name"
placeholder={t("patientForm.medName")}
value={row.name}
/>
<Input
aria-label="Dose"
aria-label={t("patientForm.dose")}
onChange={(event) => set({ dose: event.target.value })}
placeholder="Dose"
placeholder={t("patientForm.dose")}
value={row.dose}
/>
<Input
aria-label="Frequency"
aria-label={t("patientForm.frequency")}
onChange={(event) => set({ frequency: event.target.value })}
placeholder="Frequency"
placeholder={t("patientForm.frequency")}
value={row.frequency}
/>
</>
@@ -492,21 +518,21 @@ export function PatientFormDialog({
<SectionList<ProblemDraft>
blank={{ label: "", since: "" }}
label="Problems"
label={t("patientForm.problems")}
onChange={setProblems}
render={(row, set) => (
<>
<Input
aria-label="Problem"
aria-label={t("patientForm.problemAria")}
onChange={(event) => set({ label: event.target.value })}
placeholder="Diagnosis"
placeholder={t("patientForm.diagnosis")}
value={row.label}
/>
<DatePicker
ariaLabel="Since"
ariaLabel={t("patientForm.sinceAria")}
className="w-40 shrink-0"
onChange={(since) => set({ since })}
placeholder="Since"
placeholder={t("patientForm.sinceAria")}
value={row.since}
/>
</>
@@ -516,32 +542,36 @@ export function PatientFormDialog({
<SectionList<LabDraft>
blank={{ name: "", value: "", flag: "normal", takenAt: "" }}
label="Labs"
label={t("patientForm.labs")}
onChange={setLabs}
render={(row, set) => (
<>
<Input
aria-label="Lab name"
aria-label={t("patientForm.labNameAria")}
onChange={(event) => set({ name: event.target.value })}
placeholder="Test"
placeholder={t("patientForm.test")}
value={row.name}
/>
<Input
aria-label="Value"
aria-label={t("patientForm.valueAria")}
onChange={(event) => set({ value: event.target.value })}
placeholder="Value"
placeholder={t("patientForm.value")}
value={row.value}
/>
<select
aria-label="Flag"
aria-label={t("patientForm.flagAria")}
className={cn(controlClass, "w-auto")}
onChange={(event) => set({ flag: event.target.value as LabFlag })}
value={row.flag}
>
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="critical">Critical</option>
<option value="normal">
{t("patientCard.labFlag.normal")}
</option>
<option value="low">{t("patientCard.labFlag.low")}</option>
<option value="high">{t("patientCard.labFlag.high")}</option>
<option value="critical">
{t("patientCard.labFlag.critical")}
</option>
</select>
</>
)}
@@ -550,35 +580,35 @@ export function PatientFormDialog({
<SectionList<VisitDraft>
blank={{ type: "", date: "", provider: "", summary: "" }}
label="Visits"
label={t("patientForm.visits")}
onChange={setVisits}
render={(row, set) => (
<div className="flex w-full flex-col gap-2">
<div className="flex items-center gap-2">
<Input
aria-label="Visit type"
aria-label={t("patientForm.visitTypeAria")}
onChange={(event) => set({ type: event.target.value })}
placeholder="Type"
placeholder={t("patientForm.visitType")}
value={row.type}
/>
<DatePicker
ariaLabel="Visit date"
ariaLabel={t("patientForm.visitDateAria")}
className="w-40 shrink-0"
onChange={(date) => set({ date })}
placeholder="Date"
placeholder={t("patientForm.visitDate")}
value={row.date}
/>
</div>
<Input
aria-label="Provider"
aria-label={t("patientForm.providerAria")}
onChange={(event) => set({ provider: event.target.value })}
placeholder="Provider"
placeholder={t("patientForm.provider")}
value={row.provider}
/>
<Input
aria-label="Summary"
aria-label={t("patientForm.summaryAria")}
onChange={(event) => set({ summary: event.target.value })}
placeholder="Summary"
placeholder={t("patientForm.summary")}
value={row.summary}
/>
</div>
@@ -592,10 +622,14 @@ export function PatientFormDialog({
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
{t("patientForm.cancel")}
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting ? "Saving…" : isEdit ? "Save changes" : "Save patient"}
{submitting
? t("patientForm.saving")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
</DialogFooter>
</form>
@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { NotesEditor } from "@/components/notes/notes-editor";
import {
Sheet,
@@ -30,11 +32,14 @@ export function NoteDetailSheet({
onSave: (data: { title: string; content: string }) => void;
onDelete?: () => void;
}) {
const { t } = useTranslation();
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-2xl" side="right">
<SheetHeader>
<SheetTitle>{note?.id ? "Edit note" : "New note"}</SheetTitle>
<SheetTitle>
{note?.id ? t("notes.editNote") : t("notes.new")}
</SheetTitle>
</SheetHeader>
{/* Plain flex container (not SheetPanel) so the editor gets a bounded
height and scrolls internally rather than nesting two scroll areas. */}
+21 -17
View File
@@ -17,6 +17,7 @@ import {
Undo2,
} from "lucide-react";
import { type ReactNode, useReducer, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -77,6 +78,7 @@ export function NotesEditor({
onSave: (data: { title: string; content: string }) => void;
onDelete?: () => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState(note.title);
const [confirmOpen, setConfirmOpen] = useState(false);
// Force a re-render on every editor transaction so the toolbar reflects the
@@ -87,7 +89,7 @@ export function NotesEditor({
content: note.content,
extensions: [
StarterKit,
Placeholder.configure({ placeholder: "Write your note…" }),
Placeholder.configure({ placeholder: t("notes.editor.placeholder") }),
],
// Required under Next's SSR to avoid a hydration mismatch.
immediatelyRender: false,
@@ -105,7 +107,7 @@ export function NotesEditor({
const save = () =>
onSave({
title: title.trim() || "Untitled note",
title: title.trim() || t("notes.untitled"),
content: editor.getHTML(),
});
@@ -115,12 +117,12 @@ export function NotesEditor({
<Input
className="font-medium text-base"
onChange={(e) => setTitle(e.target.value)}
placeholder="Note title"
placeholder={t("notes.editor.titlePlaceholder")}
value={title}
/>
{onDelete && (
<Button
aria-label="Delete note"
aria-label={t("notes.editor.delete")}
onClick={() => setConfirmOpen(true)}
size="icon"
type="button"
@@ -131,7 +133,7 @@ export function NotesEditor({
)}
<Button disabled={saving} onClick={save} type="button">
<Save className="size-4" />
{saving ? "Saving" : "Save"}
{saving ? t("notes.editor.saving") : t("notes.editor.save")}
</Button>
</div>
@@ -139,21 +141,21 @@ export function NotesEditor({
<ToolbarGroup>
<FormatButton
active={editor.isActive("bold")}
label="Bold"
label={t("notes.editor.bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold />
</FormatButton>
<FormatButton
active={editor.isActive("italic")}
label="Italic"
label={t("notes.editor.italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic />
</FormatButton>
<FormatButton
active={editor.isActive("underline")}
label="Underline"
label={t("notes.editor.underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<UnderlineIcon />
@@ -163,7 +165,7 @@ export function NotesEditor({
<ToolbarGroup>
<FormatButton
active={editor.isActive("heading", { level: 1 })}
label="Heading 1"
label={t("notes.editor.heading1")}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
@@ -172,7 +174,7 @@ export function NotesEditor({
</FormatButton>
<FormatButton
active={editor.isActive("heading", { level: 2 })}
label="Heading 2"
label={t("notes.editor.heading2")}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
@@ -184,14 +186,14 @@ export function NotesEditor({
<ToolbarGroup>
<FormatButton
active={editor.isActive("bulletList")}
label="Bullet list"
label={t("notes.editor.bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List />
</FormatButton>
<FormatButton
active={editor.isActive("orderedList")}
label="Numbered list"
label={t("notes.editor.numberedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered />
@@ -201,14 +203,14 @@ export function NotesEditor({
<ToolbarGroup>
<FormatButton
disabled={!editor.can().undo()}
label="Undo"
label={t("notes.editor.undo")}
onClick={() => editor.chain().focus().undo().run()}
>
<Undo2 />
</FormatButton>
<FormatButton
disabled={!editor.can().redo()}
label="Redo"
label={t("notes.editor.redo")}
onClick={() => editor.chain().focus().redo().run()}
>
<Redo2 />
@@ -222,12 +224,14 @@ export function NotesEditor({
{onDelete && (
<ConfirmDialog
confirmLabel="Delete note"
description={`"${title.trim() || "Untitled note"}" will be permanently deleted. This can't be undone.`}
confirmLabel={t("notes.editor.confirmLabel")}
description={t("notes.editor.confirmDescription", {
name: title.trim() || t("notes.untitled"),
})}
onConfirm={onDelete}
onOpenChange={setConfirmOpen}
open={confirmOpen}
title="Delete this note?"
title={t("notes.editor.confirmTitle")}
/>
)}
</div>
+23 -19
View File
@@ -2,6 +2,7 @@
import { NotebookPen, Plus } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { NoteDetailSheet } from "@/components/notes/note-detail-sheet";
import { Button } from "@/components/ui/button";
@@ -31,6 +32,7 @@ const newDraft = (): Note => ({
});
export function NotesView() {
const { t } = useTranslation();
const [notes, setNotes] = useState<Note[]>([]);
// The note shown in the editor Sheet; null when the Sheet is closed.
const [selected, setSelected] = useState<Note | null>(null);
@@ -48,8 +50,8 @@ export function NotesView() {
.catch((err) => {
if (active) {
notify.error(
"Couldn't load notes",
err instanceof Error ? err.message : "Please try again.",
t("notes.loadFailed"),
err instanceof Error ? err.message : t("notes.tryAgain"),
);
}
})
@@ -81,11 +83,11 @@ export function NotesView() {
const list = await listNotes();
setNotes(list);
setSelected(list.find((n) => n.id === saved.id) ?? saved);
notify.success("Note saved");
notify.success(t("notes.saved"));
} catch (err) {
notify.error(
"Couldn't save note",
err instanceof Error ? err.message : "Please try again.",
t("notes.saveFailed"),
err instanceof Error ? err.message : t("notes.tryAgain"),
);
} finally {
setSaving(false);
@@ -99,11 +101,11 @@ export function NotesView() {
setNotes(list);
setSelected(null);
setSheetOpen(false);
notify.success("Note deleted");
notify.success(t("notes.deleted"));
} catch (err) {
notify.error(
"Couldn't delete note",
err instanceof Error ? err.message : "Please try again.",
t("notes.deleteFailed"),
err instanceof Error ? err.message : t("notes.tryAgain"),
);
}
};
@@ -112,20 +114,20 @@ export function NotesView() {
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Notes</h1>
<p className="text-muted-foreground text-sm">
Clinical notes. Click a note to open it.
</p>
<h1 className="font-semibold text-2xl tracking-tight">
{t("notes.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("notes.subtitle")}</p>
</div>
<Button className="rounded-3xl" onClick={startNew} type="button">
<Plus className="size-4" />
New note
{t("notes.new")}
</Button>
</div>
{loading ? (
<div className="rounded-2xl border bg-card/30 px-4 py-10 text-center text-muted-foreground text-sm">
Loading
{t("notes.loading")}
</div>
) : notes.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-2xl border bg-card/30 py-16">
@@ -134,15 +136,15 @@ export function NotesView() {
<EmptyMedia variant="icon">
<NotebookPen />
</EmptyMedia>
<EmptyTitle>No notes yet</EmptyTitle>
<EmptyTitle>{t("notes.emptyTitle")}</EmptyTitle>
<EmptyDescription>
Create a note to start writing.
{t("notes.emptyDescription")}
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button onClick={startNew} type="button">
<Plus className="size-4" />
New note
{t("notes.new")}
</Button>
</EmptyContent>
</Empty>
@@ -157,10 +159,12 @@ export function NotesView() {
type="button"
>
<span className="w-full truncate font-medium text-foreground text-sm">
{n.title || "Untitled note"}
{n.title || t("notes.untitled")}
</span>
<span className="text-muted-foreground text-xs">
Updated {new Date(n.updatedAt).toLocaleDateString()}
{t("notes.updated", {
date: new Date(n.updatedAt).toLocaleDateString(),
})}
</span>
</button>
))}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { PatientDetail } from "@/components/patients/patient-detail";
@@ -52,6 +53,7 @@ export function PatientDetailSheet({
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useTranslation();
const [patient, setPatient] = useState<Patient | null>(null);
const [status, setStatus] = useState<Status>("loading");
const [editOpen, setEditOpen] = useState(false);
@@ -81,8 +83,8 @@ export function PatientDetailSheet({
status === "ready" && patient
? patient.name
: status === "not-found"
? "Patient not found"
: "Loading patient…";
? t("patients.detail.notFound")
: t("patients.detail.loading");
return (
<>
@@ -95,7 +97,7 @@ export function PatientDetailSheet({
{status === "loading" && <DetailSkeleton />}
{status === "not-found" && (
<p className="text-muted-foreground text-sm">
No patient found for file #{fileNumber}.
{t("patients.detail.noPatientForFile", { number: fileNumber })}
</p>
)}
{status === "ready" && patient && (
+61 -37
View File
@@ -2,6 +2,7 @@
import { Pencil } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Sparkline } from "@/components/chat/sparkline";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -27,7 +28,6 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
inpatient: "destructive",
discharged: "outline",
};
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
function Section({ title, children }: { title: string; children: ReactNode }) {
return (
@@ -85,7 +85,9 @@ export function PatientDetail({
patient: Patient;
onEdit?: () => void;
}) {
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
const { t } = useTranslation();
const sex = t(`patientCard.sex.${patient.sex}`);
const idLine = `${patient.age} · ${sex} · MRN ${patient.fileNumber}`;
return (
<div className="flex flex-col gap-4">
@@ -98,8 +100,8 @@ export function PatientDetail({
<span className="truncate font-semibold text-base text-foreground">
{patient.name}
</span>
<Badge className="capitalize" variant={statusVariant[patient.status]}>
{patient.status}
<Badge variant={statusVariant[patient.status]}>
{t(`patients.status.${patient.status}`)}
</Badge>
</div>
<span className="text-muted-foreground text-sm">{idLine}</span>
@@ -116,36 +118,56 @@ export function PatientDetail({
{onEdit && (
<Button onClick={onEdit} size="sm" type="button" variant="outline">
<Pencil className="size-4" />
Edit
{t("patientCard.edit")}
</Button>
)}
</div>
<Section title="Overview">
<Section title={t("patientCard.overview")}>
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Stat label="Primary care" value={patient.pcp} />
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat label="Active meds" value={patient.medications.length} />
<Stat label="Open problems" value={patient.problems.length} />
<Stat
label={t("patientCard.summary.primaryCare")}
value={patient.pcp}
/>
<Stat
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</div>
</Section>
<Section title="Vitals">
<Section title={t("patientCard.vitals.title")}>
<div className="grid grid-cols-2 gap-x-4 gap-y-3 sm:grid-cols-4">
<Stat label="BP" value={patient.vitals.bp} />
<Stat label="HR" value={patient.vitals.hr} />
<Stat label="Temp" value={patient.vitals.temp} />
<Stat label="SpO₂" value={patient.vitals.spo2} />
<Stat label={t("patientCard.vitals.bp")} value={patient.vitals.bp} />
<Stat label={t("patientCard.vitals.hr")} value={patient.vitals.hr} />
<Stat
label={t("patientCard.vitals.temp")}
value={patient.vitals.temp}
/>
<Stat
label={t("patientCard.vitals.spo2")}
value={patient.vitals.spo2}
/>
</div>
<p className="mt-2 text-muted-foreground text-xs">
Taken {patient.vitals.takenAt}
{t("patientCard.vitals.taken", { at: patient.vitals.takenAt })}
</p>
<TrendBlock trend={patient.vitalsTrend} />
</Section>
<Section title="Labs">
<Section title={t("patientCard.labs.title")}>
{patient.labs.length === 0 ? (
<p className="text-muted-foreground text-sm">No labs on file.</p>
<p className="text-muted-foreground text-sm">
{t("patientCard.labs.empty")}
</p>
) : (
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
@@ -155,11 +177,8 @@ export function PatientDetail({
value={
<span className="flex items-center gap-2">
{lab.value}
<Badge
className="capitalize"
variant={labFlagVariant[lab.flag]}
>
{lab.flag}
<Badge variant={labFlagVariant[lab.flag]}>
{t(`patientCard.labFlag.${lab.flag}`)}
</Badge>
</span>
}
@@ -170,9 +189,11 @@ export function PatientDetail({
<TrendBlock trend={patient.labTrend} />
</Section>
<Section title="Medications">
<Section title={t("patientCard.medications.title")}>
{patient.medications.length === 0 ? (
<p className="text-muted-foreground text-sm">No active medications.</p>
<p className="text-muted-foreground text-sm">
{t("patientCard.medications.empty")}
</p>
) : (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
@@ -186,25 +207,29 @@ export function PatientDetail({
)}
</Section>
<Section title="Problems">
<Section title={t("patientCard.problems.title")}>
{patient.problems.length === 0 ? (
<p className="text-muted-foreground text-sm">No active problems.</p>
<p className="text-muted-foreground text-sm">
{t("patientCard.problems.empty")}
</p>
) : (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
value={t("patientCard.problems.since", { date: problem.since })}
/>
))}
</div>
)}
</Section>
<Section title="Allergies & alerts">
<Section title={t("patientCard.allergies.title")}>
{patient.allergies.length === 0 ? (
<p className="text-muted-foreground text-sm">No known allergies.</p>
<p className="text-muted-foreground text-sm">
{t("patientCard.allergies.none")}
</p>
) : (
<div className="flex flex-col gap-2">
{patient.allergies.map((allergy) => (
@@ -220,11 +245,8 @@ export function PatientDetail({
</>
}
value={
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
<Badge variant={severityVariant[allergy.severity]}>
{t(`patientCard.severity.${allergy.severity}`)}
</Badge>
}
/>
@@ -233,9 +255,11 @@ export function PatientDetail({
)}
</Section>
<Section title="Recent visits">
<Section title={t("patientCard.visits.title")}>
{patient.encounters.length === 0 ? (
<p className="text-muted-foreground text-sm">No visits yet.</p>
<p className="text-muted-foreground text-sm">
{t("patientCard.visits.empty")}
</p>
) : (
<div className="flex flex-col gap-3">
{patient.encounters.map((encounter) => (
@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -11,45 +13,55 @@ import {
} from "@/components/settings/settings-parts";
export function SigningPanel() {
const { t } = useTranslation();
return (
<>
<SettingsCard className="flex flex-col gap-6 p-6 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-4">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold tracking-tight">Signing key</h3>
<Badge className="bg-emerald-500/15 text-emerald-400">Active</Badge>
<h3 className="text-xl font-semibold tracking-tight">
{t("settings.signing.keyTitle")}
</h3>
<Badge className="bg-emerald-500/15 text-emerald-400">
{t("settings.signing.active")}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Every change you make to a patient record is signed with this key, so patients
can verify it came from you before approving it.
{t("settings.signing.keyDescription")}
</p>
<Button className={cn("rounded-lg", whiteButton)}>Rotate key</Button>
<Button className={cn("rounded-lg", whiteButton)}>
{t("settings.signing.rotateKey")}
</Button>
</div>
<div className="sm:text-right">
<p className="text-3xl font-semibold tracking-tight">Ed25519</p>
<p className="text-sm text-muted-foreground">Created May 28, 2026</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.createdAt")}
</p>
</div>
</SettingsCard>
<SettingsSection
description="The public key patients use to verify your signatures"
title="Signing identity"
description={t("settings.signing.identityDescription")}
title={t("settings.signing.identityTitle")}
>
<SettingsCard className="p-5">
<CopyField
description="Share or publish this fingerprint so patients can trust your changes"
label="Public key fingerprint"
description={t("settings.signing.fingerprintDescription")}
label={t("settings.signing.fingerprintLabel")}
value="ed25519:9f86 d081 884c 7d65 9a2f eaa0 c55a d015"
/>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Changes you've signed that are waiting on the patient's approval"
title="Signed records"
description={t("settings.signing.signedRecordsDescription")}
title={t("settings.signing.signedRecordsTitle")}
>
<SettingsCard className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">No pending signatures</p>
<p className="text-sm text-muted-foreground">
{t("settings.signing.noPending")}
</p>
</SettingsCard>
</SettingsSection>
</>
@@ -2,6 +2,7 @@
import { X } from "lucide-react";
import { type FormEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
SettingsCard,
@@ -48,6 +49,7 @@ function initials(name?: string | null, email?: string | null): string {
}
export function CareTeamPanel() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const [members, setMembers] = useState<Member[]>([]);
const [invites, setInvites] = useState<Invite[]>([]);
@@ -63,7 +65,7 @@ export function CareTeamPanel() {
const { data, error: err } =
await authClient.organization.getFullOrganization();
if (err || !data) {
setError(err?.message ?? "Could not load the care team.");
setError(err?.message ?? t("settings.careTeam.loadError"));
setLoading(false);
return;
}
@@ -96,11 +98,11 @@ export function CareTeamPanel() {
});
setInviting(false);
if (err) {
setError(err.message ?? "Could not send the invitation.");
setError(err.message ?? t("settings.careTeam.inviteError"));
return;
}
setEmail("");
setNotice(`Invitation sent to ${email.trim()}.`);
setNotice(t("settings.careTeam.inviteSent", { email: email.trim() }));
void load();
};
@@ -116,8 +118,8 @@ export function CareTeamPanel() {
return (
<SettingsSection
description="Clinicians with access to this clinic"
title="Care team"
description={t("settings.careTeam.description")}
title={t("settings.careTeam.title")}
>
{error && (
<p className="rounded-2xl bg-destructive/10 px-3 py-2 text-sm text-destructive">
@@ -139,7 +141,7 @@ export function CareTeamPanel() {
<Input
className="flex-1"
onChange={(e) => setEmail(e.target.value)}
placeholder="colleague@clinic.org"
placeholder={t("settings.careTeam.invitePlaceholder")}
required
type="email"
value={email}
@@ -158,7 +160,9 @@ export function CareTeamPanel() {
))}
</select>
<Button disabled={inviting} type="submit">
{inviting ? "Sending…" : "Invite"}
{inviting
? t("settings.careTeam.inviting")
: t("settings.careTeam.invite")}
</Button>
</form>
</SettingsCard>
@@ -167,7 +171,7 @@ export function CareTeamPanel() {
<SettingsCard className="divide-y divide-border">
{loading ? (
<p className="p-6 text-center text-sm text-muted-foreground">
Loading care team
{t("settings.careTeam.loading")}
</p>
) : (
members.map((m) => {
@@ -184,7 +188,7 @@ export function CareTeamPanel() {
{m.user?.name || m.user?.email || m.userId}
{isSelf && (
<span className="ml-1 text-xs text-muted-foreground">
(you)
{t("settings.careTeam.you")}
</span>
)}
</p>
@@ -199,7 +203,7 @@ export function CareTeamPanel() {
</Badge>
{canManage && !isSelf && m.role !== "owner" && (
<Button
aria-label="Remove member"
aria-label={t("settings.careTeam.removeMember")}
onClick={() => removeMember(m.id)}
size="icon-sm"
type="button"
@@ -217,7 +221,7 @@ export function CareTeamPanel() {
{invites.length > 0 && (
<SettingsCard className="divide-y divide-border">
<p className="px-4 py-2.5 text-xs font-medium tracking-wide text-muted-foreground uppercase">
Pending invitations
{t("settings.careTeam.pendingInvitations")}
</p>
{invites.map((inv) => (
<div className="flex items-center gap-3 px-4 py-3" key={inv.id}>
@@ -229,7 +233,7 @@ export function CareTeamPanel() {
</Badge>
{canManage && (
<Button
aria-label="Cancel invitation"
aria-label={t("settings.careTeam.cancelInvitation")}
onClick={() => cancelInvite(inv.id)}
size="icon-sm"
type="button"
@@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Switch } from "@/components/ui/switch";
@@ -78,6 +79,7 @@ export function CopyField({
description?: string;
value: string;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-0.5">
@@ -95,7 +97,7 @@ export function CopyField({
type="button"
>
<Copy className="size-3.5" />
Copy
{t("settings.copy")}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
"use client";
import { ChevronDown, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@@ -13,52 +14,36 @@ import {
ToggleRow,
} from "@/components/settings/settings-parts";
// Keys into settings.profile.notif.* — these toggles are illustrative.
const patientNotifications = [
{
title: "New lab result",
description: "Sent when a new lab result is available on a patient's chart",
},
{
title: "Record updated",
description: "Sent when a patient's record is updated by a member of the care team",
},
{
title: "Approval requested",
description: "Sent when a signed change is awaiting the patient's approval",
},
{
title: "Change approved",
description: "Sent when a patient approves a pending change to their record",
},
{
title: "New message",
description: "Sent when a patient or another clinician sends a message",
},
{
title: "Visit scheduled",
description: "Sent when an upcoming visit is added to a patient's record",
},
];
{ titleKey: "newLab", descKey: "newLabDesc" },
{ titleKey: "recordUpdated", descKey: "recordUpdatedDesc" },
{ titleKey: "approvalRequested", descKey: "approvalRequestedDesc" },
{ titleKey: "changeApproved", descKey: "changeApprovedDesc" },
{ titleKey: "newMessage", descKey: "newMessageDesc" },
{ titleKey: "visitScheduled", descKey: "visitScheduledDesc" },
] as const;
export function ProfilePanel() {
const { t } = useTranslation();
return (
<>
<SettingsSection title="Clinician profile">
<SettingsSection title={t("settings.profile.sectionTitle")}>
<SettingsCard className="space-y-6 p-5">
<CopyField
description="Your unique clinician identifier, used when signing records"
label="Clinician ID"
description={t("settings.profile.clinicianIdDescription")}
label={t("settings.profile.clinicianIdLabel")}
value="62a5278f-91c6-4912-b711-ee1c9c2f0a73"
/>
<CopyField
description="Used in your public profile and the patient portal"
label="Handle"
description={t("settings.profile.handleDescription")}
label={t("settings.profile.handleLabel")}
value="dr-khalid"
/>
<div className="flex items-end gap-4">
<div className="space-y-1.5">
<FieldLabel>Avatar</FieldLabel>
<FieldLabel>{t("settings.profile.avatar")}</FieldLabel>
<Avatar className="size-10 rounded-xl">
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
K
@@ -66,112 +51,118 @@ export function ProfilePanel() {
</Avatar>
</div>
<div className="flex-1 space-y-1.5">
<FieldLabel required>Display name</FieldLabel>
<FieldLabel required>
{t("settings.profile.displayName")}
</FieldLabel>
<Input defaultValue="Dr. Khalid" />
</div>
</div>
<div className="space-y-1.5">
<FieldLabel>Specialty</FieldLabel>
<FieldLabel>{t("settings.profile.specialty")}</FieldLabel>
<button
className="flex h-9 w-full items-center justify-between rounded-3xl bg-input/50 px-3 text-sm text-muted-foreground transition-colors hover:bg-input/70"
type="button"
>
Select specialty
{t("settings.profile.selectSpecialty")}
<ChevronDown className="size-4" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<FieldLabel>Clinic / practice</FieldLabel>
<Input placeholder="e.g. Main Hospital" />
<FieldLabel>{t("settings.profile.clinic")}</FieldLabel>
<Input placeholder={t("settings.profile.clinicPlaceholder")} />
</div>
<div className="space-y-1.5">
<FieldLabel required>Contact email</FieldLabel>
<Input placeholder="clinician@example.org" />
<FieldLabel required>
{t("settings.profile.contactEmail")}
</FieldLabel>
<Input
placeholder={t("settings.profile.contactEmailPlaceholder")}
/>
</div>
</div>
<div className="space-y-2.5">
<div className="space-y-0.5">
<FieldLabel>Professional links</FieldLabel>
<FieldLabel>{t("settings.profile.professionalLinks")}</FieldLabel>
<p className="text-xs text-muted-foreground">
Registry or institutional profiles used to verify your identity. They are
never shown to patients.
{t("settings.profile.professionalLinksHint")}
</p>
</div>
<Button className="rounded-lg" size="sm" variant="outline">
<Plus className="size-4" />
Add link
{t("settings.profile.addLink")}
</Button>
</div>
</SettingsCard>
</SettingsSection>
<SettingsSection
description="Emails sent to patients about their records, results, and pending approvals"
title="Patient notifications"
description={t("settings.profile.patientNotificationsDescription")}
title={t("settings.profile.patientNotifications")}
>
<div className="space-y-3">
{patientNotifications.map((item) => (
<ToggleRow
defaultChecked
description={item.description}
key={item.title}
title={item.title}
description={t(`settings.profile.notif.${item.descKey}`)}
key={item.titleKey}
title={t(`settings.profile.notif.${item.titleKey}`)}
/>
))}
</div>
</SettingsSection>
<SettingsSection
description="Notifications sent to you about your patients and the care team"
title="Account notifications"
description={t("settings.profile.accountNotificationsDescription")}
title={t("settings.profile.accountNotifications")}
>
<div className="space-y-3">
<ToggleRow
defaultChecked
description="Notify me when a patient approves or rejects a pending change"
title="Pending approvals"
description={t("settings.profile.notif.pendingApprovalsDesc")}
title={t("settings.profile.notif.pendingApprovals")}
/>
<ToggleRow
defaultChecked
description="Notify me when a patient shares a record with me"
title="Records shared with me"
description={t("settings.profile.notif.recordsSharedDesc")}
title={t("settings.profile.notif.recordsShared")}
/>
</div>
</SettingsSection>
<SettingsSection
description="Manage alpha & beta features for your account"
title="Features"
description={t("settings.profile.featuresDescription")}
title={t("settings.profile.features")}
>
<div className="space-y-3">
<ToggleRow
description="Write records to the patient's own device instead of your database"
title="Patient-owned storage (beta)"
description={t("settings.profile.notif.patientStorageDesc")}
title={t("settings.profile.notif.patientStorage")}
/>
<ToggleRow
description="Require a signature on every change you make to a patient record"
title="Require signed records"
description={t("settings.profile.notif.requireSignedDesc")}
title={t("settings.profile.notif.requireSigned")}
/>
</div>
</SettingsSection>
<SettingsSection
description="Irreversible actions for your account"
title="Danger Zone"
description={t("settings.profile.dangerZoneDescription")}
title={t("settings.profile.dangerZone")}
>
<SettingsCard className="flex items-center justify-between gap-4 p-4">
<div className="space-y-0.5">
<p className="text-sm font-medium">Delete account</p>
<p className="text-sm font-medium">
{t("settings.profile.deleteAccount")}
</p>
<p className="text-sm text-muted-foreground">
Permanently delete your temetro account and any locally stored signing
keys. This action cannot be undone.
{t("settings.profile.deleteAccountDescription")}
</p>
</div>
<Button variant="destructive">Delete</Button>
<Button variant="destructive">{t("settings.profile.delete")}</Button>
</SettingsCard>
</SettingsSection>
</>
@@ -178,6 +178,11 @@
"active": "Active",
"inpatient": "Inpatient",
"discharged": "Discharged"
},
"detail": {
"notFound": "Patient not found",
"loading": "Loading patient…",
"noPatientForFile": "No patient found for file #{{number}}."
}
},
"appointments": {
@@ -403,6 +408,224 @@
"totalRecorded": "Total recorded",
"empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here."
},
"notes": {
"title": "Notes",
"subtitle": "Clinical notes. Click a note to open it.",
"new": "New note",
"editNote": "Edit note",
"loading": "Loading…",
"emptyTitle": "No notes yet",
"emptyDescription": "Create a note to start writing.",
"untitled": "Untitled note",
"updated": "Updated {{date}}",
"tryAgain": "Please try again.",
"loadFailed": "Couldn't load notes",
"saved": "Note saved",
"saveFailed": "Couldn't save note",
"deleted": "Note deleted",
"deleteFailed": "Couldn't delete note",
"editor": {
"placeholder": "Write your note…",
"titlePlaceholder": "Note title",
"delete": "Delete note",
"save": "Save",
"saving": "Saving…",
"bold": "Bold",
"italic": "Italic",
"underline": "Underline",
"heading1": "Heading 1",
"heading2": "Heading 2",
"bulletList": "Bullet list",
"numberedList": "Numbered list",
"undo": "Undo",
"redo": "Redo",
"confirmTitle": "Delete this note?",
"confirmDescription": "\"{{name}}\" will be permanently deleted. This can't be undone.",
"confirmLabel": "Delete note"
}
},
"chat": {
"heading": "Which patient would you like to look up?",
"input": {
"placeholder": "Look up a patient — try /patient 10293",
"message": "Message",
"addPatient": "Add patient",
"clinical": "Clinical",
"attachFile": "Attach file",
"attachFiles": "Attach files",
"removeFile": "Remove {{name}}",
"dictate": "Dictate",
"send": "Send",
"stop": "Stop",
"accessLevel": "Access level",
"responseMode": "Response mode",
"specialty": "Specialty",
"facility": "Facility",
"timeRange": "Time range",
"access": {
"standard": "Standard access",
"breakGlass": "Break-glass (emergency)",
"readOnly": "Read-only"
},
"response": {
"concise": "Concise",
"detailed": "Detailed",
"comprehensive": "Comprehensive"
},
"specialtyOptions": {
"internalMedicine": "Internal Medicine",
"cardiology": "Cardiology",
"pediatrics": "Pediatrics",
"emergency": "Emergency",
"all": "All specialties"
},
"facilityOptions": {
"mainHospital": "Main Hospital",
"northClinic": "North Clinic",
"telehealth": "Telehealth"
},
"timeOptions": {
"30d": "Last 30 days",
"12m": "Last 12 months",
"5y": "Last 5 years",
"all": "All time"
}
}
},
"patientCard": {
"notFound": "No patient found for file #{{number}}.",
"overview": "Overview",
"edit": "Edit",
"sex": { "F": "Female", "M": "Male" },
"summary": {
"fullName": "Full name",
"mrn": "MRN",
"age": "Age",
"sex": "Sex",
"primaryCare": "Primary care",
"status": "Status",
"lastSeen": "Last seen",
"allergies": "Allergies",
"activeMeds": "Active meds",
"openProblems": "Open problems",
"none": "None",
"editRecord": "Edit record"
},
"vitals": {
"title": "Vitals",
"taken": "Taken {{at}}",
"bp": "BP",
"hr": "HR",
"temp": "Temp",
"spo2": "SpO₂"
},
"labs": {
"title": "Labs",
"asOf": "As of {{at}}",
"empty": "No labs on file."
},
"medications": {
"title": "Medications",
"active": "{{count}} active",
"empty": "No active medications."
},
"problems": {
"title": "Problems",
"active": "{{count}} active",
"empty": "No active problems.",
"since": "since {{date}}"
},
"allergies": {
"title": "Allergies & alerts",
"sectionLabel": "Allergies",
"none": "No known allergies."
},
"visits": {
"title": "Recent visits",
"recent": "{{count}} recent",
"empty": "No visits yet."
},
"trend": {
"empty": "No trend data yet.",
"last": "{{label}} · last {{count}}",
"lastReadings": "{{label}} · last {{count}} readings",
"latest": "Latest",
"min": "Min",
"max": "Max"
},
"labFlag": {
"normal": "Normal",
"low": "Low",
"high": "High",
"critical": "Critical"
},
"severity": {
"mild": "Mild",
"moderate": "Moderate",
"severe": "Severe"
}
},
"patientForm": {
"editTitle": "Edit record",
"createTitle": "Add patient",
"editDescription": "Update {{name}}'s chart and add new data.",
"createDescription": "Create a new chart. A file number has been generated for you.",
"fileNumber": "File number",
"regenerate": "Regenerate file number",
"fullName": "Full name",
"fullNamePlaceholder": "e.g. Jordan Pierce",
"age": "Age",
"sex": "Sex",
"status": "Status",
"primaryCare": "Primary care",
"primaryCarePlaceholder": "e.g. Dr. Lena Ortiz",
"currentVitals": "Current vitals",
"bp": "Blood pressure",
"hr": "Heart rate",
"temp": "Temperature",
"spo2": "Oxygen saturation",
"allergies": "Allergies",
"medications": "Medications",
"problems": "Problems",
"labs": "Labs",
"visits": "Visits",
"add": "Add",
"removeRow": "Remove {{label}} row",
"pickDate": "Pick a date",
"substance": "Substance",
"reaction": "Reaction",
"severityAria": "Severity",
"medNameAria": "Medication name",
"medName": "Name",
"dose": "Dose",
"frequency": "Frequency",
"problemAria": "Problem",
"diagnosis": "Diagnosis",
"sinceAria": "Since",
"labNameAria": "Lab name",
"test": "Test",
"valueAria": "Value",
"value": "Value",
"flagAria": "Flag",
"visitTypeAria": "Visit type",
"visitType": "Type",
"visitDateAria": "Visit date",
"visitDate": "Date",
"providerAria": "Provider",
"provider": "Provider",
"summaryAria": "Summary",
"summary": "Summary",
"cancel": "Cancel",
"saving": "Saving…",
"saveChanges": "Save changes",
"savePatient": "Save patient",
"saveError": "Could not save the patient.",
"updatedTitle": "Record updated",
"updatedBody": "{{name}}'s chart was saved.",
"addedTitle": "Patient added",
"addedBody": "{{name}} ({{fileNumber}}).",
"saveFailedTitle": "Couldn't save patient"
},
"settings": {
"tabs": {
"profile": "Profile",
@@ -412,11 +635,92 @@
"developers": "Developers"
},
"empty": "Nothing here yet.",
"copy": "Copy",
"records": {
"description": "How patient records are sourced, stored, and displayed"
},
"developers": {
"description": "Access tokens for the temetro API"
},
"profile": {
"sectionTitle": "Clinician profile",
"clinicianIdLabel": "Clinician ID",
"clinicianIdDescription": "Your unique clinician identifier, used when signing records",
"handleLabel": "Handle",
"handleDescription": "Used in your public profile and the patient portal",
"avatar": "Avatar",
"displayName": "Display name",
"specialty": "Specialty",
"selectSpecialty": "Select specialty",
"clinic": "Clinic / practice",
"clinicPlaceholder": "e.g. Main Hospital",
"contactEmail": "Contact email",
"contactEmailPlaceholder": "clinician@example.org",
"professionalLinks": "Professional links",
"professionalLinksHint": "Registry or institutional profiles used to verify your identity. They are never shown to patients.",
"addLink": "Add link",
"patientNotifications": "Patient notifications",
"patientNotificationsDescription": "Emails sent to patients about their records, results, and pending approvals",
"accountNotifications": "Account notifications",
"accountNotificationsDescription": "Notifications sent to you about your patients and the care team",
"features": "Features",
"featuresDescription": "Manage alpha & beta features for your account",
"dangerZone": "Danger Zone",
"dangerZoneDescription": "Irreversible actions for your account",
"deleteAccount": "Delete account",
"deleteAccountDescription": "Permanently delete your temetro account and any locally stored signing keys. This action cannot be undone.",
"delete": "Delete",
"notif": {
"newLab": "New lab result",
"newLabDesc": "Sent when a new lab result is available on a patient's chart",
"recordUpdated": "Record updated",
"recordUpdatedDesc": "Sent when a patient's record is updated by a member of the care team",
"approvalRequested": "Approval requested",
"approvalRequestedDesc": "Sent when a signed change is awaiting the patient's approval",
"changeApproved": "Change approved",
"changeApprovedDesc": "Sent when a patient approves a pending change to their record",
"newMessage": "New message",
"newMessageDesc": "Sent when a patient or another clinician sends a message",
"visitScheduled": "Visit scheduled",
"visitScheduledDesc": "Sent when an upcoming visit is added to a patient's record",
"pendingApprovals": "Pending approvals",
"pendingApprovalsDesc": "Notify me when a patient approves or rejects a pending change",
"recordsShared": "Records shared with me",
"recordsSharedDesc": "Notify me when a patient shares a record with me",
"patientStorage": "Patient-owned storage (beta)",
"patientStorageDesc": "Write records to the patient's own device instead of your database",
"requireSigned": "Require signed records",
"requireSignedDesc": "Require a signature on every change you make to a patient record"
}
},
"careTeam": {
"title": "Care team",
"description": "Clinicians with access to this clinic",
"loadError": "Could not load the care team.",
"invitePlaceholder": "colleague@clinic.org",
"inviting": "Sending…",
"invite": "Invite",
"inviteError": "Could not send the invitation.",
"inviteSent": "Invitation sent to {{email}}.",
"loading": "Loading care team…",
"you": "(you)",
"pendingInvitations": "Pending invitations",
"removeMember": "Remove member",
"cancelInvitation": "Cancel invitation"
},
"signing": {
"keyTitle": "Signing key",
"active": "Active",
"keyDescription": "Every change you make to a patient record is signed with this key, so patients can verify it came from you before approving it.",
"rotateKey": "Rotate key",
"createdAt": "Created May 28, 2026",
"identityTitle": "Signing identity",
"identityDescription": "The public key patients use to verify your signatures",
"fingerprintLabel": "Public key fingerprint",
"fingerprintDescription": "Share or publish this fingerprint so patients can trust your changes",
"signedRecordsTitle": "Signed records",
"signedRecordsDescription": "Changes you've signed that are waiting on the patient's approval",
"noPending": "No pending signatures"
}
}
}