mirror of
https://github.com/temetro/temetro.git
synced 2026-08-26 10:27:10 +00:00
i18n: convert feature pages, dialogs, auth sub-pages and nav to t()
Move all hardcoded UI strings in the appointments, prescriptions, tasks, messages, activity, analysis and patients views (plus their dialogs and detail sheets), the remaining auth pages (verify-email, forgot/reset password, accept-invite, onboarding), the clinic form and the sidebar user menu into i18next keys in en/translation.json. Clinical option values (appointment types, frequencies) stay canonical. English-only; no new locale yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { AlertTriangle, Search } from "lucide-react";
|
||||
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -122,6 +123,7 @@ export function AddPrescriptionDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdd: (rx: NewPrescription) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Patient | null>(null);
|
||||
@@ -177,17 +179,26 @@ export function AddPrescriptionDialog({
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selected) {
|
||||
notify.error("Pick a patient", "Search and select a patient first.");
|
||||
notify.error(
|
||||
t("prescriptions.dialog.pickPatientTitle"),
|
||||
t("prescriptions.dialog.pickPatientBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!medication.trim()) {
|
||||
notify.error("Add a medication", "Enter the medication name.");
|
||||
notify.error(
|
||||
t("prescriptions.dialog.needMedTitle"),
|
||||
t("prescriptions.dialog.needMedBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const duration =
|
||||
durationChoice === OTHER_DURATION ? durationCustom.trim() : durationChoice;
|
||||
if (durationChoice === OTHER_DURATION && !duration) {
|
||||
notify.error("Add a duration", "Enter the custom duration.");
|
||||
notify.error(
|
||||
t("prescriptions.dialog.needDurationTitle"),
|
||||
t("prescriptions.dialog.needDurationBody"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
onAdd({
|
||||
@@ -200,7 +211,10 @@ export function AddPrescriptionDialog({
|
||||
duration,
|
||||
notes: notes.trim(),
|
||||
});
|
||||
notify.success("Prescription added", `${medication.trim()} for ${selected.name}`);
|
||||
notify.success(
|
||||
t("prescriptions.dialog.addedTitle"),
|
||||
`${medication.trim()} · ${selected.name}`,
|
||||
);
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -215,15 +229,15 @@ export function AddPrescriptionDialog({
|
||||
>
|
||||
<DialogPopup className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New prescription</DialogTitle>
|
||||
<DialogTitle>{t("prescriptions.dialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Search for a patient by name or file number, then set the medication.
|
||||
{t("prescriptions.dialog.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="contents" onSubmit={submit}>
|
||||
<DialogPanel className="flex flex-col gap-4">
|
||||
<Field label="Patient">
|
||||
<Field label={t("prescriptions.dialog.patient")}>
|
||||
{selected ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-2xl border bg-input/30 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
@@ -231,7 +245,9 @@ export function AddPrescriptionDialog({
|
||||
{selected.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
File #{selected.fileNumber}
|
||||
{t("prescriptions.dialog.fileNumber", {
|
||||
number: selected.fileNumber,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -243,7 +259,7 @@ export function AddPrescriptionDialog({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Change
|
||||
{t("prescriptions.dialog.change")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
@@ -254,7 +270,7 @@ export function AddPrescriptionDialog({
|
||||
autoFocus
|
||||
className="pl-9"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search name or file number"
|
||||
placeholder={t("prescriptions.dialog.searchPlaceholder")}
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
@@ -262,7 +278,7 @@ export function AddPrescriptionDialog({
|
||||
<div className="max-h-56 overflow-y-auto rounded-2xl border bg-popover p-1">
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2 py-2 text-muted-foreground text-sm">
|
||||
No patients found.
|
||||
{t("prescriptions.dialog.noPatients")}
|
||||
</p>
|
||||
) : (
|
||||
matches.map((p) => (
|
||||
@@ -290,23 +306,23 @@ export function AddPrescriptionDialog({
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Medication">
|
||||
<Field label={t("prescriptions.dialog.medication")}>
|
||||
<Input
|
||||
onChange={(event) => setMedication(event.target.value)}
|
||||
placeholder="e.g. Amoxicillin"
|
||||
placeholder={t("prescriptions.dialog.medicationPlaceholder")}
|
||||
value={medication}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Dose">
|
||||
<Field label={t("prescriptions.dialog.dose")}>
|
||||
<Input
|
||||
onChange={(event) => setDose(event.target.value)}
|
||||
placeholder="e.g. 500 mg"
|
||||
placeholder={t("prescriptions.dialog.dosePlaceholder")}
|
||||
value={dose}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Frequency">
|
||||
<Field label={t("prescriptions.dialog.frequency")}>
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(event) => setFrequency(event.target.value)}
|
||||
@@ -321,7 +337,7 @@ export function AddPrescriptionDialog({
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="Duration">
|
||||
<Field label={t("prescriptions.dialog.duration")}>
|
||||
<select
|
||||
className={controlClass}
|
||||
onChange={(event) => setDurationChoice(event.target.value)}
|
||||
@@ -337,16 +353,16 @@ export function AddPrescriptionDialog({
|
||||
<Input
|
||||
autoFocus
|
||||
onChange={(event) => setDurationCustom(event.target.value)}
|
||||
placeholder="e.g. 21 days"
|
||||
placeholder={t("prescriptions.dialog.durationOtherPlaceholder")}
|
||||
value={durationCustom}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Notes">
|
||||
<Field label={t("prescriptions.dialog.notes")}>
|
||||
<Textarea
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
placeholder="Instructions, e.g. take with food in the morning"
|
||||
placeholder={t("prescriptions.dialog.notesPlaceholder")}
|
||||
rows={3}
|
||||
value={notes}
|
||||
/>
|
||||
@@ -355,7 +371,9 @@ export function AddPrescriptionDialog({
|
||||
{conflicts.length > 0 && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle />
|
||||
<AlertTitle>Possible interaction</AlertTitle>
|
||||
<AlertTitle>
|
||||
{t("prescriptions.dialog.interactionTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<ul className="list-disc ps-4">
|
||||
{conflicts.map((c) => (
|
||||
@@ -369,10 +387,10 @@ export function AddPrescriptionDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancel
|
||||
{t("prescriptions.dialog.cancel")}
|
||||
</DialogClose>
|
||||
<Button disabled={!selected} type="submit">
|
||||
Add prescription
|
||||
{t("prescriptions.dialog.submit")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -21,12 +23,6 @@ const statusVariant: Record<
|
||||
expired: "destructive",
|
||||
};
|
||||
|
||||
const statusLabel: Record<RxStatus, string> = {
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
expired: "Expired",
|
||||
};
|
||||
|
||||
// Right-side Sheet showing one prescription's full detail, opened by clicking a
|
||||
// row in the Prescriptions list (mirrors the Patients table → side Sheet
|
||||
// pattern). The selected record is passed in from the page.
|
||||
@@ -39,12 +35,15 @@ export function PrescriptionDetailSheet({
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetPopup className="sm:max-w-xl" side="right">
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{rx ? `${rx.medication}${rx.dose ? ` · ${rx.dose}` : ""}` : "Prescription"}
|
||||
{rx
|
||||
? `${rx.medication}${rx.dose ? ` · ${rx.dose}` : ""}`
|
||||
: t("prescriptions.detail.fallbackTitle")}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<SheetPanel className="min-h-0 flex-1">
|
||||
@@ -59,40 +58,60 @@ export function PrescriptionDetailSheet({
|
||||
{rx.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
File #{rx.fileNumber}
|
||||
{t("prescriptions.detail.fileNumber", {
|
||||
number: rx.fileNumber,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Badge className="ms-auto" variant={statusVariant[rx.status]}>
|
||||
{statusLabel[rx.status]}
|
||||
{t(`prescriptions.status.${rx.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-3 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Medication</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.medication")}
|
||||
</dt>
|
||||
<dd className="text-foreground">{rx.medication}</dd>
|
||||
{rx.dose && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Dose</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.dose")}
|
||||
</dt>
|
||||
<dd className="text-foreground">{rx.dose}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-muted-foreground">Frequency</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.frequency")}
|
||||
</dt>
|
||||
<dd className="text-foreground">{rx.frequency}</dd>
|
||||
<dt className="text-muted-foreground">Duration</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.duration")}
|
||||
</dt>
|
||||
<dd className="text-foreground">{rx.duration || "—"}</dd>
|
||||
<dt className="text-muted-foreground">Prescriber</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.prescriber")}
|
||||
</dt>
|
||||
<dd className="text-foreground">{rx.prescriber}</dd>
|
||||
<dt className="text-muted-foreground">Date</dt>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.date")}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{formatPrescribedAt(rx.prescribedAt)}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Status</dt>
|
||||
<dd className="text-foreground">{statusLabel[rx.status]}</dd>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("prescriptions.detail.status")}
|
||||
</dt>
|
||||
<dd className="text-foreground">
|
||||
{t(`prescriptions.status.${rx.status}`)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{rx.notes && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-muted-foreground text-xs">Notes</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("prescriptions.detail.notes")}
|
||||
</span>
|
||||
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
|
||||
{rx.notes}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { CircleCheck, Clock, Pill, Plus } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
AddPrescriptionDialog,
|
||||
@@ -32,12 +33,6 @@ const statusVariant: Record<
|
||||
expired: "destructive",
|
||||
};
|
||||
|
||||
const statusLabel: Record<RxStatus, string> = {
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
expired: "Expired",
|
||||
};
|
||||
|
||||
function Kpi({
|
||||
label,
|
||||
value,
|
||||
@@ -63,6 +58,7 @@ function Kpi({
|
||||
}
|
||||
|
||||
function RxRow({ rx, onOpen }: { rx: Prescription; onOpen: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/50"
|
||||
@@ -96,7 +92,9 @@ function RxRow({ rx, onOpen }: { rx: Prescription; onOpen: () => void }) {
|
||||
{formatPrescribedAt(rx.prescribedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={statusVariant[rx.status]}>{statusLabel[rx.status]}</Badge>
|
||||
<Badge variant={statusVariant[rx.status]}>
|
||||
{t(`prescriptions.status.${rx.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +122,7 @@ function Section({
|
||||
}
|
||||
|
||||
export function PrescriptionsView() {
|
||||
const { t } = useTranslation();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [list, setList] = useState<Prescription[]>([]);
|
||||
const [selected, setSelected] = useState<Prescription | null>(null);
|
||||
@@ -163,38 +162,43 @@ export function PrescriptionsView() {
|
||||
});
|
||||
setList((prev) => [created, ...prev]);
|
||||
} catch {
|
||||
notify.error("Couldn't add prescription", "Please try again.");
|
||||
notify.error(
|
||||
t("prescriptions.addFailedTitle"),
|
||||
t("prescriptions.addFailedBody"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const kpis = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "Active",
|
||||
label: t("prescriptions.kpi.active"),
|
||||
value: String(list.filter((r) => r.status === "active").length),
|
||||
icon: Pill,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
label: t("prescriptions.kpi.completed"),
|
||||
value: String(list.filter((r) => r.status === "completed").length),
|
||||
icon: CircleCheck,
|
||||
},
|
||||
{
|
||||
label: "Expired",
|
||||
label: t("prescriptions.kpi.expired"),
|
||||
value: String(list.filter((r) => r.status === "expired").length),
|
||||
icon: Clock,
|
||||
},
|
||||
],
|
||||
[list],
|
||||
[list, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 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">Prescriptions</h1>
|
||||
<h1 className="font-semibold text-2xl tracking-tight">
|
||||
{t("prescriptions.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Medications prescribed across the clinic.
|
||||
{t("prescriptions.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -203,7 +207,7 @@ export function PrescriptionsView() {
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New prescription
|
||||
{t("prescriptions.new")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -213,7 +217,10 @@ export function PrescriptionsView() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Section description="Most recent first" title="Recent prescriptions">
|
||||
<Section
|
||||
description={t("prescriptions.recentDescription")}
|
||||
title={t("prescriptions.recent")}
|
||||
>
|
||||
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
|
||||
{list.map((rx) => (
|
||||
<RxRow key={rx.id} onOpen={() => openRx(rx)} rx={rx} />
|
||||
|
||||
Reference in New Issue
Block a user