mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 10:09:39 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user