mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 04:47:57 +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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user