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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-08 02:23:20 +03:00
parent e0de20b551
commit ab2f10bffc
17 changed files with 850 additions and 364 deletions
+64 -42
View File
@@ -19,9 +19,11 @@ import {
type KeyboardEvent,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import {
@@ -40,34 +42,35 @@ type ChatInputProps = {
};
type Option = { value: string; label: string };
type OptionKey = { value: string; labelKey: string };
const ACCESS_OPTIONS: Option[] = [
{ value: "standard", label: "Standard access" },
{ value: "break-glass", label: "Break-glass (emergency)" },
{ value: "read-only", label: "Read-only" },
const ACCESS_OPTIONS: OptionKey[] = [
{ value: "standard", labelKey: "chat.input.access.standard" },
{ value: "break-glass", labelKey: "chat.input.access.breakGlass" },
{ value: "read-only", labelKey: "chat.input.access.readOnly" },
];
const RESPONSE_OPTIONS: Option[] = [
{ value: "concise", label: "Concise" },
{ value: "detailed", label: "Detailed" },
{ value: "comprehensive", label: "Comprehensive" },
const RESPONSE_OPTIONS: OptionKey[] = [
{ value: "concise", labelKey: "chat.input.response.concise" },
{ value: "detailed", labelKey: "chat.input.response.detailed" },
{ value: "comprehensive", labelKey: "chat.input.response.comprehensive" },
];
const SPECIALTY_OPTIONS: Option[] = [
{ value: "internal-medicine", label: "Internal Medicine" },
{ value: "cardiology", label: "Cardiology" },
{ value: "pediatrics", label: "Pediatrics" },
{ value: "emergency", label: "Emergency" },
{ value: "all", label: "All specialties" },
const SPECIALTY_OPTIONS: OptionKey[] = [
{ value: "internal-medicine", labelKey: "chat.input.specialtyOptions.internalMedicine" },
{ value: "cardiology", labelKey: "chat.input.specialtyOptions.cardiology" },
{ value: "pediatrics", labelKey: "chat.input.specialtyOptions.pediatrics" },
{ value: "emergency", labelKey: "chat.input.specialtyOptions.emergency" },
{ value: "all", labelKey: "chat.input.specialtyOptions.all" },
];
const FACILITY_OPTIONS: Option[] = [
{ value: "main-hospital", label: "Main Hospital" },
{ value: "north-clinic", label: "North Clinic" },
{ value: "telehealth", label: "Telehealth" },
const FACILITY_OPTIONS: OptionKey[] = [
{ value: "main-hospital", labelKey: "chat.input.facilityOptions.mainHospital" },
{ value: "north-clinic", labelKey: "chat.input.facilityOptions.northClinic" },
{ value: "telehealth", labelKey: "chat.input.facilityOptions.telehealth" },
];
const TIME_OPTIONS: Option[] = [
{ value: "30d", label: "Last 30 days" },
{ value: "12m", label: "Last 12 months" },
{ value: "5y", label: "Last 5 years" },
{ value: "all", label: "All time" },
const TIME_OPTIONS: OptionKey[] = [
{ value: "30d", labelKey: "chat.input.timeOptions.30d" },
{ value: "12m", labelKey: "chat.input.timeOptions.12m" },
{ value: "5y", labelKey: "chat.input.timeOptions.5y" },
{ value: "all", labelKey: "chat.input.timeOptions.all" },
];
const iconButton =
@@ -128,6 +131,21 @@ function SelectPill({
}
export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
const { t } = useTranslation();
const toOptions = useCallback(
(opts: OptionKey[]): Option[] =>
opts.map((o) => ({ value: o.value, label: t(o.labelKey) })),
[t],
);
const accessOptions = useMemo(() => toOptions(ACCESS_OPTIONS), [toOptions]);
const responseOptions = useMemo(() => toOptions(RESPONSE_OPTIONS), [toOptions]);
const specialtyOptions = useMemo(
() => toOptions(SPECIALTY_OPTIONS),
[toOptions],
);
const facilityOptions = useMemo(() => toOptions(FACILITY_OPTIONS), [toOptions]);
const timeOptions = useMemo(() => toOptions(TIME_OPTIONS), [toOptions]);
const [value, setValue] = useState("");
const [files, setFiles] = useState<File[]>([]);
const [access, setAccess] = useState("standard");
@@ -203,11 +221,11 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
{/* Top (lighter) card: textarea + toolbar, with a slightly smaller bottom radius */}
<div className="rounded-b-[22px] bg-input">
<textarea
aria-label="Message"
aria-label={t("chat.input.message")}
className="field-sizing-content block max-h-48 min-h-16 w-full resize-none bg-transparent px-5 pt-5 pb-2 text-base text-foreground outline-none placeholder:text-muted-foreground"
onChange={(event) => setValue(event.target.value)}
onKeyDown={handleKeyDown}
placeholder="Look up a patient — try /patient 10293"
placeholder={t("chat.input.placeholder")}
rows={1}
value={value}
/>
@@ -221,7 +239,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
>
<span className="max-w-40 truncate">{file.name}</span>
<button
aria-label={`Remove ${file.name}`}
aria-label={t("chat.input.removeFile", { name: file.name })}
className="text-muted-foreground transition-colors hover:text-foreground"
onClick={() => removeFile(index)}
type="button"
@@ -234,7 +252,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
)}
<input
aria-label="Attach files"
aria-label={t("chat.input.attachFiles")}
className="hidden"
multiple
onChange={handleFilesSelected}
@@ -245,7 +263,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
<div className="flex items-center justify-between gap-2 px-3 pb-3">
<div className="flex min-w-0 items-center gap-1">
<button
aria-label="Attach file"
aria-label={t("chat.input.attachFile")}
className={iconButton}
onClick={() => fileInputRef.current?.click()}
type="button"
@@ -253,11 +271,11 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
<Plus className="size-[18px]" />
</button>
<SelectPill
ariaLabel="Access level"
ariaLabel={t("chat.input.accessLevel")}
chevronClassName="size-4 opacity-70"
icon={<Hand className="size-4" />}
onValueChange={setAccess}
options={ACCESS_OPTIONS}
options={accessOptions}
triggerClassName={pillButton}
value={access}
/>
@@ -266,20 +284,24 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
<div className="flex shrink-0 items-center gap-1">
<SelectPill
align="end"
ariaLabel="Response mode"
ariaLabel={t("chat.input.responseMode")}
chevronClassName="size-4 opacity-70"
icon={null}
onValueChange={setResponseMode}
options={RESPONSE_OPTIONS}
prefix="Clinical"
options={responseOptions}
prefix={t("chat.input.clinical")}
triggerClassName={cn(pillButton, "mr-1")}
value={responseMode}
/>
<button aria-label="Dictate" className={iconButton} type="button">
<button
aria-label={t("chat.input.dictate")}
className={iconButton}
type="button"
>
<Mic className="size-[18px]" />
</button>
<button
aria-label={isGenerating ? "Stop" : "Send"}
aria-label={isGenerating ? t("chat.input.stop") : t("chat.input.send")}
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
canSend || isGenerating
@@ -303,29 +325,29 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
{/* Bottom (darker) card peeking out below, more rounded: context selectors */}
<div className="flex flex-wrap items-center gap-1 px-3 pt-2.5 pb-3">
<SelectPill
ariaLabel="Specialty"
ariaLabel={t("chat.input.specialty")}
chevronClassName="size-3.5 opacity-70"
icon={<Stethoscope className="size-4" />}
onValueChange={setSpecialty}
options={SPECIALTY_OPTIONS}
options={specialtyOptions}
triggerClassName={contextPill}
value={specialty}
/>
<SelectPill
ariaLabel="Facility"
ariaLabel={t("chat.input.facility")}
chevronClassName="size-3.5 opacity-70"
icon={<Building2 className="size-4" />}
onValueChange={setFacility}
options={FACILITY_OPTIONS}
options={facilityOptions}
triggerClassName={contextPill}
value={facility}
/>
<SelectPill
ariaLabel="Time range"
ariaLabel={t("chat.input.timeRange")}
chevronClassName="size-3.5 opacity-70"
icon={<CalendarRange className="size-4" />}
onValueChange={setTimeRange}
options={TIME_OPTIONS}
options={timeOptions}
triggerClassName={contextPill}
value={timeRange}
/>
@@ -338,7 +360,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
type="button"
>
<UserPlus className="size-4" />
<span>Add patient</span>
<span>{t("chat.input.addPatient")}</span>
</button>
</div>
</form>
+3 -3
View File
@@ -4,6 +4,7 @@ import { nanoid } from "nanoid";
import type { ChatStatus } from "ai";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Conversation,
@@ -31,12 +32,11 @@ type ChatMessage =
patient?: Patient;
};
const HEADING = "Which patient would you like to look up?";
// Trigger: `/patient 10293` or just `/10293` pulls up records.
const PATIENT_COMMAND = /^\/(?:patient\s+)?(\d+)$/i;
export function ChatPanel() {
const { t } = useTranslation();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<ChatStatus>("ready");
@@ -135,7 +135,7 @@ export function ChatPanel() {
<div className="flex flex-1 flex-col items-center justify-center px-4">
<div className="flex w-full max-w-3xl flex-col items-center gap-10">
<h1 className="text-center text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
{HEADING}
{t("chat.heading")}
</h1>
{promptInput}
</div>
+121 -73
View File
@@ -2,6 +2,7 @@
import { Pencil } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -59,8 +60,6 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
discharged: "outline",
};
const sexLabel: Record<Patient["sex"], string> = { F: "Female", M: "Male" };
// Fixed width so the cards sit in a horizontal scroll row instead of squashing,
// plus a subtle clickable affordance (they open a detail dialog).
const rowCard =
@@ -102,13 +101,19 @@ function Empty({ children }: { children: ReactNode }) {
}
function TrendBlock({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
return <Empty>{t("patientCard.trend.empty")}</Empty>;
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length}`}</SectionLabel>
<SectionLabel>
{t("patientCard.trend.last", {
label: trend.label,
count: trend.points.length,
})}
</SectionLabel>
<span className="text-foreground">
{trend.points.at(-1)}
<span className="text-muted-foreground"> {trend.unit}</span>
@@ -120,27 +125,35 @@ function TrendBlock({ trend }: { trend: Trend }) {
}
function TrendDetail({ trend }: { trend: Trend }) {
const { t } = useTranslation();
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
return <Empty>{t("patientCard.trend.empty")}</Empty>;
}
const min = Math.min(...trend.points);
const max = Math.max(...trend.points);
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<SectionLabel>{`${trend.label} · last ${trend.points.length} readings`}</SectionLabel>
<SectionLabel>
{t("patientCard.trend.lastReadings", {
label: trend.label,
count: trend.points.length,
})}
</SectionLabel>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>
Latest{" "}
{t("patientCard.trend.latest")}{" "}
<span className="text-foreground">
{trend.points.at(-1)} {trend.unit}
</span>
</span>
<span>
Min <span className="text-foreground">{min}</span>
{t("patientCard.trend.min")}{" "}
<span className="text-foreground">{min}</span>
</span>
<span>
Max <span className="text-foreground">{max}</span>
{t("patientCard.trend.max")}{" "}
<span className="text-foreground">{max}</span>
</span>
</div>
</div>
@@ -204,26 +217,29 @@ function SummaryCard({
patient: Patient;
onEdit?: () => void;
}) {
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
const { t } = useTranslation();
const sex = t(`patientCard.sex.${patient.sex}`);
const statusLabel = t(`patients.status.${patient.status}`);
const idLine = `${patient.age} · ${sex} · MRN ${patient.fileNumber}`;
return (
<ExpandableCard
description={idLine}
detail={
<div className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<Stat label="Full name" value={patient.name} />
<Stat label="MRN" value={patient.fileNumber} />
<Stat label="Age" value={patient.age} />
<Stat label="Sex" value={sexLabel[patient.sex]} />
<Stat label="Primary care" value={patient.pcp} />
<Stat label={t("patientCard.summary.fullName")} value={patient.name} />
<Stat label={t("patientCard.summary.mrn")} value={patient.fileNumber} />
<Stat label={t("patientCard.summary.age")} value={patient.age} />
<Stat label={t("patientCard.summary.sex")} value={sex} />
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
<Stat label={t("patientCard.summary.status")} value={statusLabel} />
<Stat
label="Status"
value={<span className="capitalize">{patient.status}</span>}
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat
label="Allergies"
value={patient.allergies.length || "None"}
label={t("patientCard.summary.allergies")}
value={patient.allergies.length || t("patientCard.summary.none")}
/>
</div>
<AlertBadges alerts={patient.alerts} />
@@ -240,17 +256,24 @@ function SummaryCard({
<CardTitle>{patient.name}</CardTitle>
<CardDescription>{idLine}</CardDescription>
</div>
<Badge className="capitalize" variant={statusVariant[patient.status]}>
{patient.status}
</Badge>
<Badge variant={statusVariant[patient.status]}>{statusLabel}</Badge>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-x-3 gap-y-3">
<Stat label="Primary care" value={patient.pcp} />
<Stat label="Last seen" value={patient.encounters[0]?.date ?? "—"} />
<Stat label="Active meds" value={patient.medications.length} />
<Stat label="Open problems" value={patient.problems.length} />
<Stat label={t("patientCard.summary.primaryCare")} value={patient.pcp} />
<Stat
label={t("patientCard.summary.lastSeen")}
value={patient.encounters[0]?.date ?? "—"}
/>
<Stat
label={t("patientCard.summary.activeMeds")}
value={patient.medications.length}
/>
<Stat
label={t("patientCard.summary.openProblems")}
value={patient.problems.length}
/>
</div>
<AlertBadges alerts={patient.alerts} />
<button
@@ -262,7 +285,7 @@ function SummaryCard({
type="button"
>
<Pencil className="size-4" />
Edit record
{t("patientCard.summary.editRecord")}
</button>
</CardContent>
</ExpandableCard>
@@ -270,12 +293,13 @@ function SummaryCard({
}
function VitalsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
const { vitals } = patient;
const vitalItems = [
{ label: "BP", value: vitals.bp },
{ label: "HR", value: vitals.hr },
{ label: "Temp", value: vitals.temp },
{ label: "SpO₂", value: vitals.spo2 },
{ label: t("patientCard.vitals.bp"), value: vitals.bp },
{ label: t("patientCard.vitals.hr"), value: vitals.hr },
{ label: t("patientCard.vitals.temp"), value: vitals.temp },
{ label: t("patientCard.vitals.spo2"), value: vitals.spo2 },
];
const vitalsGrid = (gapY: string) => (
<div className={cn("grid grid-cols-2 gap-x-4", gapY)}>
@@ -287,7 +311,7 @@ function VitalsCard({ patient }: { patient: Patient }) {
return (
<ExpandableCard
description={`Taken ${vitals.takenAt}`}
description={t("patientCard.vitals.taken", { at: vitals.takenAt })}
detail={
<div className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
@@ -295,11 +319,13 @@ function VitalsCard({ patient }: { patient: Patient }) {
<TrendDetail trend={patient.vitalsTrend} />
</div>
}
title="Vitals"
title={t("patientCard.vitals.title")}
>
<CardHeader>
<CardTitle>Vitals</CardTitle>
<CardDescription>Taken {vitals.takenAt}</CardDescription>
<CardTitle>{t("patientCard.vitals.title")}</CardTitle>
<CardDescription>
{t("patientCard.vitals.taken", { at: vitals.takenAt })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{vitalsGrid("gap-y-3")}
@@ -310,24 +336,26 @@ function VitalsCard({ patient }: { patient: Patient }) {
);
}
function labValue(value: string, flag: LabFlag) {
function LabValue({ value, flag }: { value: string; flag: LabFlag }) {
const { t } = useTranslation();
return (
<span className="flex items-center gap-2">
{value}
<Badge className="capitalize" variant={labFlagVariant[flag]}>
{flag}
</Badge>
<Badge variant={labFlagVariant[flag]}>{t(`patientCard.labFlag.${flag}`)}</Badge>
</span>
);
}
function LabsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<ExpandableCard
description={`As of ${patient.labs[0]?.takenAt ?? "—"}`}
description={t("patientCard.labs.asOf", {
at: patient.labs[0]?.takenAt ?? "—",
})}
detail={
patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
<Empty>{t("patientCard.labs.empty")}</Empty>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5">
@@ -342,7 +370,7 @@ function LabsCard({ patient }: { patient: Patient }) {
{lab.takenAt}
</span>
</div>
{labValue(lab.value, lab.flag)}
<LabValue flag={lab.flag} value={lab.value} />
</div>
))}
</div>
@@ -351,15 +379,17 @@ function LabsCard({ patient }: { patient: Patient }) {
</div>
)
}
title="Labs"
title={t("patientCard.labs.title")}
>
<CardHeader>
<CardTitle>Labs</CardTitle>
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
<CardTitle>{t("patientCard.labs.title")}</CardTitle>
<CardDescription>
{t("patientCard.labs.asOf", { at: patient.labs[0]?.takenAt ?? "—" })}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
<Empty>{t("patientCard.labs.empty")}</Empty>
) : (
<>
<div className="flex flex-col gap-2">
@@ -367,7 +397,7 @@ function LabsCard({ patient }: { patient: Patient }) {
<Row
key={lab.name}
label={lab.name}
value={labValue(lab.value, lab.flag)}
value={<LabValue flag={lab.flag} value={lab.value} />}
/>
))}
</div>
@@ -381,9 +411,10 @@ function LabsCard({ patient }: { patient: Patient }) {
}
function MedicationsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
const list =
patient.medications.length === 0 ? (
<Empty>No active medications.</Empty>
<Empty>{t("patientCard.medications.empty")}</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
@@ -397,13 +428,19 @@ function MedicationsCard({ patient }: { patient: Patient }) {
);
return (
<ExpandableCard
description={`${patient.medications.length} active`}
description={t("patientCard.medications.active", {
count: patient.medications.length,
})}
detail={list}
title="Medications"
title={t("patientCard.medications.title")}
>
<CardHeader>
<CardTitle>Medications</CardTitle>
<CardDescription>{patient.medications.length} active</CardDescription>
<CardTitle>{t("patientCard.medications.title")}</CardTitle>
<CardDescription>
{t("patientCard.medications.active", {
count: patient.medications.length,
})}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
@@ -411,29 +448,34 @@ function MedicationsCard({ patient }: { patient: Patient }) {
}
function ProblemsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
const list =
patient.problems.length === 0 ? (
<Empty>No active problems.</Empty>
<Empty>{t("patientCard.problems.empty")}</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
value={t("patientCard.problems.since", { date: problem.since })}
/>
))}
</div>
);
return (
<ExpandableCard
description={`${patient.problems.length} active`}
description={t("patientCard.problems.active", {
count: patient.problems.length,
})}
detail={list}
title="Problems"
title={t("patientCard.problems.title")}
>
<CardHeader>
<CardTitle>Problems</CardTitle>
<CardDescription>{patient.problems.length} active</CardDescription>
<CardTitle>{t("patientCard.problems.title")}</CardTitle>
<CardDescription>
{t("patientCard.problems.active", { count: patient.problems.length })}
</CardDescription>
</CardHeader>
<CardContent>{list}</CardContent>
</ExpandableCard>
@@ -441,13 +483,16 @@ function ProblemsCard({ patient }: { patient: Patient }) {
}
function AllergiesList({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<AlertBadges alerts={patient.alerts} />
<div className="flex flex-col gap-2">
<SectionLabel>Allergies</SectionLabel>
<SectionLabel>{t("patientCard.allergies.sectionLabel")}</SectionLabel>
{patient.allergies.length === 0 ? (
<p className="text-muted-foreground">No known allergies.</p>
<p className="text-muted-foreground">
{t("patientCard.allergies.none")}
</p>
) : (
patient.allergies.map((allergy) => (
<Row
@@ -462,11 +507,8 @@ function AllergiesList({ patient }: { patient: Patient }) {
</>
}
value={
<Badge
className="capitalize"
variant={severityVariant[allergy.severity]}
>
{allergy.severity}
<Badge variant={severityVariant[allergy.severity]}>
{t(`patientCard.severity.${allergy.severity}`)}
</Badge>
}
/>
@@ -478,13 +520,14 @@ function AllergiesList({ patient }: { patient: Patient }) {
}
function AllergiesCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<ExpandableCard
detail={<AllergiesList patient={patient} />}
title="Allergies & alerts"
title={t("patientCard.allergies.title")}
>
<CardHeader>
<CardTitle>Allergies & alerts</CardTitle>
<CardTitle>{t("patientCard.allergies.title")}</CardTitle>
</CardHeader>
<CardContent>
<AllergiesList patient={patient} />
@@ -494,8 +537,9 @@ function AllergiesCard({ patient }: { patient: Patient }) {
}
function VisitsList({ patient }: { patient: Patient }) {
const { t } = useTranslation();
if (patient.encounters.length === 0) {
return <Empty>No visits yet.</Empty>;
return <Empty>{t("patientCard.visits.empty")}</Empty>;
}
return (
<div className="flex flex-col gap-3">
@@ -521,14 +565,17 @@ function VisitsList({ patient }: { patient: Patient }) {
}
function VisitsCard({ patient }: { patient: Patient }) {
const { t } = useTranslation();
return (
<ExpandableCard
description={`${patient.encounters.length} recent`}
description={t("patientCard.visits.recent", {
count: patient.encounters.length,
})}
detail={<VisitsList patient={patient} />}
title="Recent visits"
title={t("patientCard.visits.title")}
>
<CardHeader>
<CardTitle>Recent visits</CardTitle>
<CardTitle>{t("patientCard.visits.title")}</CardTitle>
</CardHeader>
<CardContent>
<VisitsList patient={patient} />
@@ -580,6 +627,7 @@ export function PatientResult({
onPatientUpdated,
layout = "row",
}: PatientResultProps) {
const { t } = useTranslation();
const [editOpen, setEditOpen] = useState(false);
// Bumped on open so the editor remounts with the latest patient data.
const [editKey, setEditKey] = useState(0);
@@ -589,7 +637,7 @@ export function PatientResult({
<Card className={compactCard}>
<CardContent>
<p className="text-muted-foreground">
No patient found for file #{fileNumber}.
{t("patientCard.notFound", { number: fileNumber })}
</p>
</CardContent>
</Card>
+110 -76
View File
@@ -2,6 +2,7 @@
import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
@@ -72,6 +73,7 @@ function SectionList<T>({
onChange: (rows: T[]) => void;
render: (row: T, set: (patch: Partial<T>) => void) => ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
@@ -85,7 +87,7 @@ function SectionList<T>({
variant="ghost"
>
<Plus className="size-4" />
Add
{t("patientForm.add")}
</Button>
</div>
{rows.map((row, index) => (
@@ -94,7 +96,7 @@ function SectionList<T>({
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
)}
<button
aria-label={`Remove ${label} row`}
aria-label={t("patientForm.removeRow", { label })}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => onChange(rows.filter((_, i) => i !== index))}
type="button"
@@ -145,7 +147,7 @@ function DatePicker({
value,
onChange,
ariaLabel,
placeholder = "Pick a date",
placeholder,
className,
}: {
value: string;
@@ -154,7 +156,9 @@ function DatePicker({
placeholder?: string;
className?: string;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const placeholderText = placeholder ?? t("patientForm.pickDate");
return (
<Popover onOpenChange={setOpen} open={open}>
@@ -173,7 +177,7 @@ function DatePicker({
}
>
<CalendarIcon className="size-4" />
<span className="truncate">{value || placeholder}</span>
<span className="truncate">{value || placeholderText}</span>
</PopoverTrigger>
<PopoverPopup className="w-auto p-0">
<Calendar
@@ -197,6 +201,7 @@ export function PatientFormDialog({
onCreated,
onSaved,
}: PatientFormDialogProps) {
const { t } = useTranslation();
const isEdit = mode === "edit";
const [submitting, setSubmitting] = useState(false);
@@ -290,17 +295,26 @@ export function PatientFormDialog({
: await createPatient(built);
if (isEdit) {
onSaved?.(saved);
notify.success("Record updated", `${saved.name}'s chart was saved.`);
notify.success(
t("patientForm.updatedTitle"),
t("patientForm.updatedBody", { name: saved.name }),
);
} else {
onCreated?.(saved.fileNumber);
notify.success("Patient added", `${saved.name} (${saved.fileNumber}).`);
notify.success(
t("patientForm.addedTitle"),
t("patientForm.addedBody", {
name: saved.name,
fileNumber: saved.fileNumber,
}),
);
}
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : "Could not save the patient.";
err instanceof Error ? err.message : t("patientForm.saveError");
setError(message);
notify.error("Couldn't save patient", message);
notify.error(t("patientForm.saveFailedTitle"), message);
} finally {
setSubmitting(false);
}
@@ -310,11 +324,15 @@ export function PatientFormDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit record" : "Add patient"}</DialogTitle>
<DialogTitle>
{isEdit ? t("patientForm.editTitle") : t("patientForm.createTitle")}
</DialogTitle>
<DialogDescription>
{isEdit
? `Update ${patient?.name ?? "this"}'s chart and add new data.`
: "Create a new chart. A file number has been generated for you."}
? t("patientForm.editDescription", {
name: patient?.name ?? "this",
})
: t("patientForm.createDescription")}
</DialogDescription>
</DialogHeader>
@@ -323,12 +341,12 @@ export function PatientFormDialog({
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
<Field label="File number">
<Field label={t("patientForm.fileNumber")}>
<div className="flex items-center gap-2">
<Input readOnly value={fileNumber} />
{!isEdit && (
<Button
aria-label="Regenerate file number"
aria-label={t("patientForm.regenerate")}
onClick={() => setFileNumber(generateFileNumber())}
size="icon"
type="button"
@@ -340,18 +358,18 @@ export function PatientFormDialog({
</div>
</Field>
<Field label="Full name">
<Field label={t("patientForm.fullName")}>
<Input
autoFocus={!isEdit}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Jordan Pierce"
placeholder={t("patientForm.fullNamePlaceholder")}
required
value={name}
/>
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="Age">
<Field label={t("patientForm.age")}>
<Input
inputMode="numeric"
onChange={(event) => setAge(event.target.value)}
@@ -359,7 +377,7 @@ export function PatientFormDialog({
value={age}
/>
</Field>
<Field label="Sex">
<Field label={t("patientForm.sex")}>
<select
className={controlClass}
onChange={(event) =>
@@ -367,11 +385,11 @@ export function PatientFormDialog({
}
value={sex}
>
<option value="F">Female</option>
<option value="M">Male</option>
<option value="F">{t("patientCard.sex.F")}</option>
<option value="M">{t("patientCard.sex.M")}</option>
</select>
</Field>
<Field label="Status">
<Field label={t("patientForm.status")}>
<select
className={controlClass}
onChange={(event) =>
@@ -379,48 +397,52 @@ export function PatientFormDialog({
}
value={status}
>
<option value="active">Active</option>
<option value="inpatient">Inpatient</option>
<option value="discharged">Discharged</option>
<option value="active">{t("patients.status.active")}</option>
<option value="inpatient">
{t("patients.status.inpatient")}
</option>
<option value="discharged">
{t("patients.status.discharged")}
</option>
</select>
</Field>
</div>
<Field label="Primary care">
<Field label={t("patientForm.primaryCare")}>
<Input
onChange={(event) => setPcp(event.target.value)}
placeholder="e.g. Dr. Lena Ortiz"
placeholder={t("patientForm.primaryCarePlaceholder")}
value={pcp}
/>
</Field>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Current vitals
{t("patientForm.currentVitals")}
</span>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Input
aria-label="Blood pressure"
aria-label={t("patientForm.bp")}
onChange={(event) => setBp(event.target.value)}
placeholder="BP"
placeholder={t("patientCard.vitals.bp")}
value={bp}
/>
<Input
aria-label="Heart rate"
aria-label={t("patientForm.hr")}
onChange={(event) => setHr(event.target.value)}
placeholder="HR"
placeholder={t("patientCard.vitals.hr")}
value={hr}
/>
<Input
aria-label="Temperature"
aria-label={t("patientForm.temp")}
onChange={(event) => setTemp(event.target.value)}
placeholder="Temp"
placeholder={t("patientCard.vitals.temp")}
value={temp}
/>
<Input
aria-label="Oxygen saturation"
aria-label={t("patientForm.spo2")}
onChange={(event) => setSpo2(event.target.value)}
placeholder="SpO₂"
placeholder={t("patientCard.vitals.spo2")}
value={spo2}
/>
</div>
@@ -428,33 +450,37 @@ export function PatientFormDialog({
<SectionList<AllergyDraft>
blank={{ substance: "", reaction: "", severity: "mild" }}
label="Allergies"
label={t("patientForm.allergies")}
onChange={setAllergies}
render={(row, set) => (
<>
<Input
aria-label="Substance"
aria-label={t("patientForm.substance")}
onChange={(event) => set({ substance: event.target.value })}
placeholder="Substance"
placeholder={t("patientForm.substance")}
value={row.substance}
/>
<Input
aria-label="Reaction"
aria-label={t("patientForm.reaction")}
onChange={(event) => set({ reaction: event.target.value })}
placeholder="Reaction"
placeholder={t("patientForm.reaction")}
value={row.reaction}
/>
<select
aria-label="Severity"
aria-label={t("patientForm.severityAria")}
className={cn(controlClass, "w-auto")}
onChange={(event) =>
set({ severity: event.target.value as AllergySeverity })
}
value={row.severity}
>
<option value="mild">Mild</option>
<option value="moderate">Moderate</option>
<option value="severe">Severe</option>
<option value="mild">{t("patientCard.severity.mild")}</option>
<option value="moderate">
{t("patientCard.severity.moderate")}
</option>
<option value="severe">
{t("patientCard.severity.severe")}
</option>
</select>
</>
)}
@@ -463,26 +489,26 @@ export function PatientFormDialog({
<SectionList<MedicationDraft>
blank={{ name: "", dose: "", frequency: "" }}
label="Medications"
label={t("patientForm.medications")}
onChange={setMedications}
render={(row, set) => (
<>
<Input
aria-label="Medication name"
aria-label={t("patientForm.medNameAria")}
onChange={(event) => set({ name: event.target.value })}
placeholder="Name"
placeholder={t("patientForm.medName")}
value={row.name}
/>
<Input
aria-label="Dose"
aria-label={t("patientForm.dose")}
onChange={(event) => set({ dose: event.target.value })}
placeholder="Dose"
placeholder={t("patientForm.dose")}
value={row.dose}
/>
<Input
aria-label="Frequency"
aria-label={t("patientForm.frequency")}
onChange={(event) => set({ frequency: event.target.value })}
placeholder="Frequency"
placeholder={t("patientForm.frequency")}
value={row.frequency}
/>
</>
@@ -492,21 +518,21 @@ export function PatientFormDialog({
<SectionList<ProblemDraft>
blank={{ label: "", since: "" }}
label="Problems"
label={t("patientForm.problems")}
onChange={setProblems}
render={(row, set) => (
<>
<Input
aria-label="Problem"
aria-label={t("patientForm.problemAria")}
onChange={(event) => set({ label: event.target.value })}
placeholder="Diagnosis"
placeholder={t("patientForm.diagnosis")}
value={row.label}
/>
<DatePicker
ariaLabel="Since"
ariaLabel={t("patientForm.sinceAria")}
className="w-40 shrink-0"
onChange={(since) => set({ since })}
placeholder="Since"
placeholder={t("patientForm.sinceAria")}
value={row.since}
/>
</>
@@ -516,32 +542,36 @@ export function PatientFormDialog({
<SectionList<LabDraft>
blank={{ name: "", value: "", flag: "normal", takenAt: "" }}
label="Labs"
label={t("patientForm.labs")}
onChange={setLabs}
render={(row, set) => (
<>
<Input
aria-label="Lab name"
aria-label={t("patientForm.labNameAria")}
onChange={(event) => set({ name: event.target.value })}
placeholder="Test"
placeholder={t("patientForm.test")}
value={row.name}
/>
<Input
aria-label="Value"
aria-label={t("patientForm.valueAria")}
onChange={(event) => set({ value: event.target.value })}
placeholder="Value"
placeholder={t("patientForm.value")}
value={row.value}
/>
<select
aria-label="Flag"
aria-label={t("patientForm.flagAria")}
className={cn(controlClass, "w-auto")}
onChange={(event) => set({ flag: event.target.value as LabFlag })}
value={row.flag}
>
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="critical">Critical</option>
<option value="normal">
{t("patientCard.labFlag.normal")}
</option>
<option value="low">{t("patientCard.labFlag.low")}</option>
<option value="high">{t("patientCard.labFlag.high")}</option>
<option value="critical">
{t("patientCard.labFlag.critical")}
</option>
</select>
</>
)}
@@ -550,35 +580,35 @@ export function PatientFormDialog({
<SectionList<VisitDraft>
blank={{ type: "", date: "", provider: "", summary: "" }}
label="Visits"
label={t("patientForm.visits")}
onChange={setVisits}
render={(row, set) => (
<div className="flex w-full flex-col gap-2">
<div className="flex items-center gap-2">
<Input
aria-label="Visit type"
aria-label={t("patientForm.visitTypeAria")}
onChange={(event) => set({ type: event.target.value })}
placeholder="Type"
placeholder={t("patientForm.visitType")}
value={row.type}
/>
<DatePicker
ariaLabel="Visit date"
ariaLabel={t("patientForm.visitDateAria")}
className="w-40 shrink-0"
onChange={(date) => set({ date })}
placeholder="Date"
placeholder={t("patientForm.visitDate")}
value={row.date}
/>
</div>
<Input
aria-label="Provider"
aria-label={t("patientForm.providerAria")}
onChange={(event) => set({ provider: event.target.value })}
placeholder="Provider"
placeholder={t("patientForm.provider")}
value={row.provider}
/>
<Input
aria-label="Summary"
aria-label={t("patientForm.summaryAria")}
onChange={(event) => set({ summary: event.target.value })}
placeholder="Summary"
placeholder={t("patientForm.summary")}
value={row.summary}
/>
</div>
@@ -592,10 +622,14 @@ export function PatientFormDialog({
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
{t("patientForm.cancel")}
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting ? "Saving…" : isEdit ? "Save changes" : "Save patient"}
{submitting
? t("patientForm.saving")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
</DialogFooter>
</form>