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:
Khalid Abdi
2026-06-07 21:57:00 +03:00
parent 288d14758f
commit e0de20b551
20 changed files with 837 additions and 319 deletions
+16 -12
View File
@@ -3,12 +3,14 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { AuthShell, FormAlert } from "@/components/auth/auth-ui";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
function AcceptInviteInner() {
const { t } = useTranslation();
const router = useRouter();
const params = useSearchParams();
const invitationId = params.get("id") ?? "";
@@ -24,7 +26,7 @@ function AcceptInviteInner() {
invitationId,
});
if (err || !data) {
setError(err?.message ?? "This invitation is invalid or has expired.");
setError(err?.message ?? t("auth.acceptInvite.error"));
setAccepting(false);
return;
}
@@ -37,8 +39,8 @@ function AcceptInviteInner() {
if (!invitationId) {
return (
<AuthShell title="Invitation not found">
<FormAlert>This invitation link is missing or invalid.</FormAlert>
<AuthShell title={t("auth.acceptInvite.notFoundTitle")}>
<FormAlert>{t("auth.acceptInvite.notFoundBody")}</FormAlert>
</AuthShell>
);
}
@@ -48,8 +50,8 @@ function AcceptInviteInner() {
const back = `/accept-invite?id=${encodeURIComponent(invitationId)}`;
return (
<AuthShell
subtitle="Sign in with the email this invitation was sent to, then return to this link to accept."
title="You've been invited"
subtitle={t("auth.acceptInvite.invitedSubtitle")}
title={t("auth.acceptInvite.invitedTitle")}
>
<div className="flex flex-col gap-3">
<Button
@@ -57,7 +59,7 @@ function AcceptInviteInner() {
onClick={() => router.push("/login")}
type="button"
>
Sign in
{t("auth.acceptInvite.signIn")}
</Button>
<Button
className="w-full"
@@ -65,12 +67,12 @@ function AcceptInviteInner() {
type="button"
variant="outline"
>
Create an account
{t("auth.acceptInvite.createAccount")}
</Button>
<p className="text-center text-xs text-muted-foreground">
After signing in, reopen{" "}
{t("auth.acceptInvite.reopenPrefix")}{" "}
<Link className="hover:underline" href={back}>
this invitation link
{t("auth.acceptInvite.reopenLink")}
</Link>
.
</p>
@@ -81,8 +83,8 @@ function AcceptInviteInner() {
return (
<AuthShell
subtitle="Join the clinic you were invited to on temetro."
title="Accept your invitation"
subtitle={t("auth.acceptInvite.acceptSubtitle")}
title={t("auth.acceptInvite.acceptTitle")}
>
<div className="flex flex-col gap-4">
{error && <FormAlert>{error}</FormAlert>}
@@ -92,7 +94,9 @@ function AcceptInviteInner() {
onClick={accept}
type="button"
>
{accepting ? "Joining…" : "Accept invitation"}
{accepting
? t("auth.acceptInvite.accepting")
: t("auth.acceptInvite.accept")}
</Button>
</div>
</AuthShell>
+17 -11
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
import { Button } from "@/components/ui/button";
@@ -10,6 +11,7 @@ import { authClient } from "@/lib/auth-client";
import { notify } from "@/lib/toast";
export default function ForgotPasswordPage() {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -28,12 +30,15 @@ export default function ForgotPasswordPage() {
setSubmitting(false);
if (err) {
const message = err.message ?? "Could not send the reset email.";
const message = err.message ?? t("auth.forgotPassword.error");
setError(message);
notify.error("Couldn't send reset link", message);
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
return;
}
notify.success("Reset link sent", "Check your inbox for the link.");
notify.success(
t("auth.forgotPassword.sentToastTitle"),
t("auth.forgotPassword.sentToastBody"),
);
setSent(true);
};
@@ -41,33 +46,34 @@ export default function ForgotPasswordPage() {
<AuthShell
footer={
<Link className="text-foreground hover:underline" href="/login">
Back to sign in
{t("common.backToSignIn")}
</Link>
}
subtitle="We'll email you a link to reset your password"
title="Reset your password"
subtitle={t("auth.forgotPassword.subtitle")}
title={t("auth.forgotPassword.title")}
>
{sent ? (
<FormAlert tone="success">
If an account exists for {email}, a reset link is on its way. Check
your inbox.
{t("auth.forgotPassword.sent", { email })}
</FormAlert>
) : (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
{error && <FormAlert>{error}</FormAlert>}
<Field htmlFor="email" label="Email">
<Field htmlFor="email" label={t("auth.forgotPassword.emailLabel")}>
<Input
autoComplete="email"
id="email"
onChange={(e) => setEmail(e.target.value)}
placeholder="you@clinic.org"
placeholder={t("auth.forgotPassword.emailPlaceholder")}
required
type="email"
value={email}
/>
</Field>
<Button className="mt-1 w-full" disabled={submitting} type="submit">
{submitting ? "Sending…" : "Send reset link"}
{submitting
? t("auth.forgotPassword.submitting")
: t("auth.forgotPassword.submit")}
</Button>
</form>
)}
+4 -2
View File
@@ -2,12 +2,14 @@
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { AuthShell } from "@/components/auth/auth-ui";
import { CreateClinicForm } from "@/components/clinic/create-clinic-form";
import { authClient } from "@/lib/auth-client";
export default function OnboardingPage() {
const { t } = useTranslation();
const router = useRouter();
const { data: session, isPending } = authClient.useSession();
@@ -20,8 +22,8 @@ export default function OnboardingPage() {
return (
<AuthShell
subtitle="Create your clinic to start organizing patient records"
title="Set up your clinic"
subtitle={t("auth.onboarding.subtitle")}
title={t("auth.onboarding.title")}
>
<CreateClinicForm onCreated={() => router.push("/")} />
</AuthShell>
+24 -16
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { type FormEvent, Suspense, useState } from "react";
import { useTranslation } from "react-i18next";
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
import { Button } from "@/components/ui/button";
@@ -13,6 +14,7 @@ import { notify } from "@/lib/toast";
const MIN_PASSWORD = 12;
function ResetPasswordInner() {
const { t } = useTranslation();
const router = useRouter();
const params = useSearchParams();
const token = params.get("token") ?? "";
@@ -28,15 +30,15 @@ function ResetPasswordInner() {
setError(null);
if (!token) {
setError("This reset link is invalid or has expired.");
setError(t("auth.resetPassword.invalidToken"));
return;
}
if (password.length < MIN_PASSWORD) {
setError(`Password must be at least ${MIN_PASSWORD} characters.`);
setError(t("auth.resetPassword.tooShort", { count: MIN_PASSWORD }));
return;
}
if (password !== confirm) {
setError("Passwords do not match.");
setError(t("auth.resetPassword.mismatch"));
return;
}
@@ -47,12 +49,15 @@ function ResetPasswordInner() {
});
setSubmitting(false);
if (err) {
const message = err.message ?? "Could not reset your password.";
const message = err.message ?? t("auth.resetPassword.error");
setError(message);
notify.error("Couldn't reset password", message);
notify.error(t("auth.resetPassword.failedToastTitle"), message);
return;
}
notify.success("Password updated", "Redirecting you to sign in…");
notify.success(
t("auth.resetPassword.successToastTitle"),
t("auth.resetPassword.successToastBody"),
);
setDone(true);
setTimeout(() => router.push("/login"), 1500);
};
@@ -61,23 +66,21 @@ function ResetPasswordInner() {
<AuthShell
footer={
<Link className="text-foreground hover:underline" href="/login">
Back to sign in
{t("common.backToSignIn")}
</Link>
}
subtitle="Choose a new password for your account"
title="Set a new password"
subtitle={t("auth.resetPassword.subtitle")}
title={t("auth.resetPassword.title")}
>
{done ? (
<FormAlert tone="success">
Your password has been reset. Redirecting you to sign in
</FormAlert>
<FormAlert tone="success">{t("auth.resetPassword.done")}</FormAlert>
) : (
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
{error && <FormAlert>{error}</FormAlert>}
<Field
hint={`At least ${MIN_PASSWORD} characters.`}
hint={t("auth.resetPassword.passwordHint", { count: MIN_PASSWORD })}
htmlFor="password"
label="New password"
label={t("auth.resetPassword.passwordLabel")}
>
<Input
autoComplete="new-password"
@@ -89,7 +92,10 @@ function ResetPasswordInner() {
value={password}
/>
</Field>
<Field htmlFor="confirm" label="Confirm new password">
<Field
htmlFor="confirm"
label={t("auth.resetPassword.confirmLabel")}
>
<Input
autoComplete="new-password"
id="confirm"
@@ -101,7 +107,9 @@ function ResetPasswordInner() {
/>
</Field>
<Button className="mt-1 w-full" disabled={submitting} type="submit">
{submitting ? "Saving…" : "Reset password"}
{submitting
? t("auth.resetPassword.submitting")
: t("auth.resetPassword.submit")}
</Button>
</form>
)}
+11 -15
View File
@@ -3,12 +3,14 @@
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AuthShell, FormAlert } from "@/components/auth/auth-ui";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
function VerifyEmailInner() {
const { t } = useTranslation();
const router = useRouter();
const params = useSearchParams();
const email = params.get("email") ?? "";
@@ -34,31 +36,25 @@ function VerifyEmailInner() {
});
setSending(false);
if (err) {
setError(err.message ?? "Could not resend the verification email.");
setError(err.message ?? t("auth.verifyEmail.resendError"));
return;
}
setNotice("Verification email sent. Check your inbox.");
setNotice(t("auth.verifyEmail.resent"));
};
return (
<AuthShell
footer={
<Link className="text-foreground hover:underline" href="/login">
Back to sign in
{t("common.backToSignIn")}
</Link>
}
subtitle={
email ? (
<>
We sent a verification link to{" "}
<span className="text-foreground">{email}</span>. Open it to activate
your account.
</>
) : (
"Open the verification link we emailed you to activate your account."
)
email
? t("auth.verifyEmail.subtitle", { email })
: t("auth.verifyEmail.subtitleNoEmail")
}
title="Check your inbox"
title={t("auth.verifyEmail.title")}
>
<div className="flex flex-col gap-4">
{notice && <FormAlert tone="success">{notice}</FormAlert>}
@@ -68,7 +64,7 @@ function VerifyEmailInner() {
onClick={() => router.push("/")}
type="button"
>
I&apos;ve verified continue
{t("auth.verifyEmail.continue")}
</Button>
{email && (
<Button
@@ -78,7 +74,7 @@ function VerifyEmailInner() {
type="button"
variant="outline"
>
{sending ? "Sending" : "Resend email"}
{sending ? t("auth.verifyEmail.resending") : t("auth.verifyEmail.resend")}
</Button>
)}
</div>
+19 -10
View File
@@ -13,6 +13,7 @@ import {
Stethoscope,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Card } from "@/components/ui/card";
@@ -80,6 +81,7 @@ function Kpi({
}
export function ActivityView() {
const { t } = useTranslation();
const [entries, setEntries] = useState<ActivityEntry[]>([]);
useEffect(() => {
@@ -108,19 +110,27 @@ export function ActivityView() {
const today = entries.filter((e) => new Date(e.createdAt) >= startOfToday);
const week = entries.filter((e) => new Date(e.createdAt) >= startOfWeek);
return [
{ label: "Changes today", value: String(today.length), icon: ActivityIcon },
{ label: "This week", value: String(week.length), icon: CalendarDays },
{ label: "Total recorded", value: String(entries.length), icon: Hash },
{
label: t("activity.changesToday"),
value: String(today.length),
icon: ActivityIcon,
},
{ label: t("activity.thisWeek"), value: String(week.length), icon: CalendarDays },
{
label: t("activity.totalRecorded"),
value: String(entries.length),
icon: Hash,
},
];
}, [entries]);
}, [entries, t]);
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-10 px-6 py-10">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Activity</h1>
<p className="text-muted-foreground text-sm">
An audit log of record changes across the clinic.
</p>
<h1 className="font-semibold text-2xl tracking-tight">
{t("activity.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("activity.subtitle")}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
@@ -131,8 +141,7 @@ export function ActivityView() {
{entries.length === 0 ? (
<div className="rounded-2xl border border-dashed bg-card/20 px-4 py-12 text-center text-muted-foreground text-sm">
No activity yet. Changes to patients, notes, appointments,
prescriptions and tasks will appear here.
{t("activity.empty")}
</div>
) : (
<ol className="flex flex-col">
+59 -21
View File
@@ -1,6 +1,7 @@
"use client";
import { type ReactNode, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card } from "@/components/ui/card";
import { type Analytics, getAnalytics } from "@/lib/analytics";
@@ -45,6 +46,7 @@ function Section({
}
export function AnalysisView() {
const { t } = useTranslation();
const [data, setData] = useState<Analytics | null>(null);
useEffect(() => {
@@ -66,39 +68,75 @@ export function AnalysisView() {
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-6 py-10">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Analysis</h1>
<p className="text-muted-foreground text-sm">
Clinic performance at a glance, computed from your clinic&apos;s data.
</p>
<h1 className="font-semibold text-2xl tracking-tight">
{t("analysis.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("analysis.subtitle")}</p>
</div>
<Section
description="New, active and total patients"
title="Patient volume"
description={t("analysis.patientVolume.description")}
title={t("analysis.patientVolume.title")}
>
<StatCard label="Total patients" value={n(data?.patients.total)} />
<StatCard label="New this month" value={n(data?.patients.newThisMonth)} />
<StatCard label="Active patients" value={n(data?.patients.active)} />
<StatCard
label={t("analysis.patientVolume.total")}
value={n(data?.patients.total)}
/>
<StatCard
label={t("analysis.patientVolume.newThisMonth")}
value={n(data?.patients.newThisMonth)}
/>
<StatCard
label={t("analysis.patientVolume.active")}
value={n(data?.patients.active)}
/>
</Section>
<Section
description="Bookings, attendance and what's coming up"
title="Appointments & schedule"
description={t("analysis.appointments.description")}
title={t("analysis.appointments.title")}
>
<StatCard label="This week" value={n(data?.appointments.thisWeek)} />
<StatCard label="Upcoming" value={n(data?.appointments.upcoming)} />
<StatCard label="Completed" value={n(data?.appointments.completed)} />
<StatCard label="Cancelled" value={n(data?.appointments.cancelled)} />
<StatCard
label={t("analysis.appointments.thisWeek")}
value={n(data?.appointments.thisWeek)}
/>
<StatCard
label={t("analysis.appointments.upcoming")}
value={n(data?.appointments.upcoming)}
/>
<StatCard
label={t("analysis.appointments.completed")}
value={n(data?.appointments.completed)}
/>
<StatCard
label={t("analysis.appointments.cancelled")}
value={n(data?.appointments.cancelled)}
/>
</Section>
<Section description="Medications prescribed" title="Prescriptions">
<StatCard label="Total issued" value={n(data?.prescriptions.total)} />
<StatCard label="Active" value={n(data?.prescriptions.active)} />
<Section
description={t("analysis.prescriptions.description")}
title={t("analysis.prescriptions.title")}
>
<StatCard
label={t("analysis.prescriptions.total")}
value={n(data?.prescriptions.total)}
/>
<StatCard
label={t("analysis.prescriptions.active")}
value={n(data?.prescriptions.active)}
/>
</Section>
<Section description="Care-team workload" title="Tasks">
<StatCard label="Open" value={n(data?.tasks.open)} />
<StatCard label="Completed" value={n(data?.tasks.done)} />
<Section
description={t("analysis.tasks.description")}
title={t("analysis.tasks.title")}
>
<StatCard label={t("analysis.tasks.open")} value={n(data?.tasks.open)} />
<StatCard
label={t("analysis.tasks.completed")}
value={n(data?.tasks.done)}
/>
</Section>
</div>
);
@@ -2,6 +2,7 @@
import { CalendarDays, Search } from "lucide-react";
import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TODAY } from "@/components/appointments/appointments-view";
import { Button } from "@/components/ui/button";
@@ -69,6 +70,7 @@ export function AddAppointmentDialog({
onOpenChange: (open: boolean) => void;
onAdd: (appt: NewAppointment) => void;
}) {
const { t } = useTranslation();
const [patients, setPatients] = useState<Patient[]>([]);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Patient | null>(null);
@@ -118,7 +120,10 @@ export function AddAppointmentDialog({
const submit = (event: FormEvent) => {
event.preventDefault();
if (!selected) {
notify.error("Pick a patient", "Search and select a patient first.");
notify.error(
t("appointments.dialog.pickPatientTitle"),
t("appointments.dialog.pickPatientBody"),
);
return;
}
onAdd({
@@ -130,7 +135,10 @@ export function AddAppointmentDialog({
type,
provider: provider.trim() || selected.pcp,
});
notify.success("Appointment added", `${selected.name} at ${time}`);
notify.success(
t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`,
);
reset();
onOpenChange(false);
};
@@ -145,15 +153,15 @@ export function AddAppointmentDialog({
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New appointment</DialogTitle>
<DialogTitle>{t("appointments.dialog.title")}</DialogTitle>
<DialogDescription>
Search for a patient by name or file number, then set the slot.
{t("appointments.dialog.description")}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label="Patient">
<Field label={t("appointments.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">
@@ -161,7 +169,9 @@ export function AddAppointmentDialog({
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
File #{selected.fileNumber}
{t("appointments.dialog.fileNumber", {
number: selected.fileNumber,
})}
</span>
</div>
<Button
@@ -173,7 +183,7 @@ export function AddAppointmentDialog({
type="button"
variant="ghost"
>
Change
{t("appointments.dialog.change")}
</Button>
</div>
) : (
@@ -184,7 +194,7 @@ export function AddAppointmentDialog({
autoFocus
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search name or file number"
placeholder={t("appointments.dialog.searchPlaceholder")}
value={query}
/>
</div>
@@ -192,7 +202,7 @@ export function AddAppointmentDialog({
<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("appointments.dialog.noPatients")}
</p>
) : (
matches.map((p) => (
@@ -222,7 +232,9 @@ export function AddAppointmentDialog({
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Date</span>
<span className="text-muted-foreground text-xs">
{t("appointments.dialog.date")}
</span>
<Popover onOpenChange={setDateOpen} open={dateOpen}>
<PopoverTrigger
render={
@@ -254,7 +266,7 @@ export function AddAppointmentDialog({
</PopoverPopup>
</Popover>
</div>
<Field label="Time">
<Field label={t("appointments.dialog.time")}>
<Input
onChange={(event) => setTime(event.target.value)}
type="time"
@@ -264,23 +276,23 @@ export function AddAppointmentDialog({
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Type">
<Field label={t("appointments.dialog.type")}>
<select
className={controlClass}
onChange={(event) => setType(event.target.value)}
value={type}
>
{TYPES.map((t) => (
<option key={t} value={t}>
{t}
{TYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
<Field label="Provider">
<Field label={t("appointments.dialog.provider")}>
<Input
onChange={(event) => setProvider(event.target.value)}
placeholder="e.g. Dr. Okafor"
placeholder={t("appointments.dialog.providerPlaceholder")}
value={provider}
/>
</Field>
@@ -289,10 +301,10 @@ export function AddAppointmentDialog({
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
{t("appointments.dialog.cancel")}
</DialogClose>
<Button disabled={!selected} type="submit">
Add appointment
{t("appointments.dialog.submit")}
</Button>
</DialogFooter>
</form>
@@ -10,6 +10,7 @@ import {
Users,
} from "lucide-react";
import { type ReactNode, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AddAppointmentDialog,
@@ -60,13 +61,6 @@ const statusVariant: Record<
cancelled: "destructive",
};
const statusLabel: Record<ApptStatus, string> = {
"checked-in": "Checked in",
confirmed: "Confirmed",
completed: "Completed",
cancelled: "Cancelled",
};
// "2026-06-05" -> "Wednesday, June 5"
function formatDayKey(key: string): string {
return new Date(`${key}T00:00:00`).toLocaleDateString("en-US", {
@@ -105,6 +99,7 @@ function Kpi({
}
function ApptRow({ appt }: { appt: Appointment }) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="w-12 shrink-0 font-medium text-foreground text-sm tabular-nums">
@@ -121,7 +116,9 @@ function ApptRow({ appt }: { appt: Appointment }) {
{appt.type} · {appt.provider}
</span>
</div>
<Badge variant={statusVariant[appt.status]}>{statusLabel[appt.status]}</Badge>
<Badge variant={statusVariant[appt.status]}>
{t(`appointments.status.${appt.status}`)}
</Badge>
</div>
);
}
@@ -159,6 +156,7 @@ function Section({
}
export function AppointmentsView() {
const { t } = useTranslation();
const [addOpen, setAddOpen] = useState(false);
const [calendarOpen, setCalendarOpen] = useState(false);
const [appointments, setAppointments] = useState<Appointment[]>([]);
@@ -192,7 +190,10 @@ export function AppointmentsView() {
});
setAppointments((prev) => [...prev, created]);
} catch {
notify.error("Couldn't add appointment", "Please try again.");
notify.error(
t("appointments.addFailedTitle"),
t("appointments.addFailedBody"),
);
}
};
@@ -201,20 +202,20 @@ export function AppointmentsView() {
const today = appointments.filter((a) => a.date === TODAY);
const week = appointments.filter((a) => a.date >= start && a.date <= end);
return [
{ label: "Today", value: String(today.length), icon: CalendarClock },
{ label: "This week", value: String(week.length), icon: Users },
{ label: t("appointments.kpi.today"), value: String(today.length), icon: CalendarClock },
{ label: t("appointments.kpi.thisWeek"), value: String(week.length), icon: Users },
{
label: "Checked in",
label: t("appointments.kpi.checkedIn"),
value: String(today.filter((a) => a.status === "checked-in").length),
icon: Stethoscope,
},
{
label: "Completed",
label: t("appointments.kpi.completed"),
value: String(today.filter((a) => a.status === "completed").length),
icon: Clock,
},
];
}, [appointments]);
}, [appointments, t]);
const todayItems = useMemo(
() => appointments.filter((a) => a.date === TODAY).sort(byTime),
@@ -256,10 +257,10 @@ export function AppointmentsView() {
<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">
Appointments &amp; Schedule
{t("appointments.title")}
</h1>
<p className="text-muted-foreground text-sm">
Today&apos;s clinic schedule and what&apos;s coming up.
{t("appointments.subtitle")}
</p>
</div>
<div className="flex items-center gap-2">
@@ -268,7 +269,7 @@ export function AppointmentsView() {
<Input
className="w-full pl-9 sm:w-64"
onChange={(event) => setQuery(event.target.value)}
placeholder="Search patient, type, provider"
placeholder={t("appointments.searchPlaceholder")}
value={query}
/>
</div>
@@ -279,7 +280,7 @@ export function AppointmentsView() {
variant="outline"
>
<CalendarDays className="size-4" />
Calendar
{t("appointments.calendar")}
</Button>
<Button
className="rounded-3xl"
@@ -287,7 +288,7 @@ export function AppointmentsView() {
type="button"
>
<Plus className="size-4" />
Add
{t("appointments.add")}
</Button>
</div>
</div>
@@ -301,7 +302,7 @@ export function AppointmentsView() {
))
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
No matching appointments.
{t("appointments.noMatches")}
</p>
)
) : (
@@ -312,12 +313,12 @@ export function AppointmentsView() {
))}
</div>
<Section description={formatDayKey(TODAY)} title="Today">
<Section description={formatDayKey(TODAY)} title={t("appointments.today")}>
{todayItems.length > 0 ? (
<ScheduleList items={todayItems} />
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
Nothing scheduled today.
{t("appointments.nothingToday")}
</p>
)}
</Section>
@@ -2,6 +2,7 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
type Appointment,
@@ -57,6 +58,7 @@ export function CalendarDialog({
onOpenChange: (open: boolean) => void;
appointments: Appointment[];
}) {
const { t } = useTranslation();
// First-of-month for the displayed month; defaults to TODAY's month.
const [viewMonth, setViewMonth] = useState<Date>(() => {
const t = parseKey(TODAY);
@@ -120,7 +122,7 @@ export function CalendarDialog({
type="button"
variant="outline"
>
Today
{t("appointments.calendarDialog.today")}
</Button>
<Button
aria-label="Previous month"
@@ -214,16 +216,16 @@ export function CalendarDialog({
{formatDayKey(selectedKey)}
</h3>
<p className="text-muted-foreground text-xs">
{selectedItems.length === 1
? "1 appointment"
: `${selectedItems.length} appointments`}
{t("appointments.calendarDialog.appointmentCount", {
count: selectedItems.length,
})}
</p>
</div>
{selectedItems.length > 0 ? (
<ScheduleList items={selectedItems} />
) : (
<div className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
No appointments on this day.
{t("appointments.calendarDialog.none")}
</div>
)}
</div>
@@ -1,6 +1,7 @@
"use client";
import { type FormEvent, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -20,11 +21,12 @@ function slugify(value: string): string {
// clinic menu's "Create clinic" dialog.
export function CreateClinicForm({
onCreated,
submitLabel = "Create clinic",
submitLabel,
}: {
onCreated?: (org: { id: string; name: string }) => void;
submitLabel?: string;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugEdited, setSlugEdited] = useState(false);
@@ -42,15 +44,15 @@ export function CreateClinicForm({
await authClient.organization.create({ name: name.trim(), slug: finalSlug });
if (createErr || !org) {
const message = createErr?.message ?? "Could not create the clinic.";
const message = createErr?.message ?? t("clinic.createError");
setError(message);
notify.error("Couldn't create clinic", message);
notify.error(t("clinic.createFailedTitle"), message);
setSubmitting(false);
return;
}
await authClient.organization.setActive({ organizationId: org.id });
notify.success("Clinic created", `${org.name} is ready.`);
notify.success(t("clinic.createdTitle"), t("clinic.createdBody", { name: org.name }));
onCreated?.(org);
};
@@ -66,7 +68,7 @@ export function CreateClinicForm({
className="font-medium text-foreground text-sm"
htmlFor="clinic-name"
>
Clinic name
{t("clinic.nameLabel")}
</label>
<Input
id="clinic-name"
@@ -74,7 +76,7 @@ export function CreateClinicForm({
setName(e.target.value);
if (!slugEdited) setSlug(slugify(e.target.value));
}}
placeholder="North Side Family Practice"
placeholder={t("clinic.namePlaceholder")}
required
value={name}
/>
@@ -84,7 +86,7 @@ export function CreateClinicForm({
className="font-medium text-foreground text-sm"
htmlFor="clinic-slug"
>
Clinic URL slug
{t("clinic.slugLabel")}
</label>
<Input
id="clinic-slug"
@@ -92,16 +94,14 @@ export function CreateClinicForm({
setSlugEdited(true);
setSlug(slugify(e.target.value));
}}
placeholder="north-side-family-practice"
placeholder={t("clinic.slugPlaceholder")}
required
value={slug}
/>
<p className="text-muted-foreground text-xs">
Used in links and invitations. Lowercase letters, numbers and dashes.
</p>
<p className="text-muted-foreground text-xs">{t("clinic.slugHint")}</p>
</div>
<Button className="mt-1 w-full" disabled={submitting} type="submit">
{submitting ? "Creating clinic…" : submitLabel}
{submitting ? t("clinic.creating") : (submitLabel ?? t("clinic.create"))}
</Button>
</form>
);
+29 -17
View File
@@ -8,6 +8,7 @@ import {
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
@@ -59,6 +60,7 @@ function formatTime(iso: string): string {
}
export function MessagesView() {
const { t } = useTranslation();
const { data: session } = authClient.useSession();
const myId = session?.user?.id ?? "";
@@ -182,7 +184,10 @@ export function MessagesView() {
setComposeOpen(false);
open(conv.id);
} catch {
notify.error("Couldn't start conversation", "Please try again.");
notify.error(
t("messages.startFailedTitle"),
t("messages.startFailedBody"),
);
}
};
@@ -191,7 +196,9 @@ export function MessagesView() {
{/* Left: conversation list */}
<aside className="flex w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-card/30">
<div className="flex items-center justify-between gap-2 border-border border-b px-4 py-3">
<h1 className="font-semibold text-base tracking-tight">Inbox</h1>
<h1 className="font-semibold text-base tracking-tight">
{t("messages.inbox")}
</h1>
<div className="flex items-center gap-1">
<Button
aria-pressed={showUnreadOnly}
@@ -200,10 +207,10 @@ export function MessagesView() {
type="button"
variant={showUnreadOnly ? "secondary" : "ghost"}
>
Unread · {unreadCount}
{t("messages.unread", { count: unreadCount })}
</Button>
<Button
aria-label="New message"
aria-label={t("messages.newMessage")}
onClick={openCompose}
size="icon-sm"
type="button"
@@ -216,7 +223,9 @@ export function MessagesView() {
<div className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-2">
{visible.length === 0 ? (
<p className="px-2 py-1.5 text-muted-foreground text-sm">
{showUnreadOnly ? "No unread messages." : "No conversations yet."}
{showUnreadOnly
? t("messages.noUnread")
: t("messages.noConversations")}
</p>
) : (
visible.map((c) => {
@@ -250,7 +259,7 @@ export function MessagesView() {
</span>
</div>
<span className="w-full truncate text-muted-foreground text-xs">
{last?.senderId === myId && "You: "}
{last?.senderId === myId && t("messages.you")}
{last?.body}
</span>
</button>
@@ -274,8 +283,10 @@ export function MessagesView() {
</span>
<span className="text-muted-foreground text-xs">
{selected.isGroup
? `${selected.participants.length} people`
: "Direct message"}
? t("messages.peopleCount", {
count: selected.participants.length,
})
: t("messages.directMessage")}
</span>
</div>
</div>
@@ -324,14 +335,16 @@ export function MessagesView() {
onSubmit={send}
>
<Input
aria-label="Message"
aria-label={t("messages.newMessage")}
className="border-0 bg-transparent shadow-none before:hidden"
onChange={(e) => setDraft(e.target.value)}
placeholder={`Message ${selected.name}`}
placeholder={t("messages.messagePlaceholder", {
name: selected.name,
})}
value={draft}
/>
<Button
aria-label="Send"
aria-label={t("messages.send")}
disabled={!draft.trim()}
size="icon"
type="submit"
@@ -347,9 +360,9 @@ export function MessagesView() {
<EmptyMedia variant="icon">
<Mail />
</EmptyMedia>
<EmptyTitle>No conversation selected</EmptyTitle>
<EmptyTitle>{t("messages.emptyTitle")}</EmptyTitle>
<EmptyDescription>
Choose a conversation from the inbox, or start a new one.
{t("messages.emptyDescription")}
</EmptyDescription>
</EmptyHeader>
</Empty>
@@ -361,16 +374,15 @@ export function MessagesView() {
<Dialog onOpenChange={setComposeOpen} open={composeOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New message</DialogTitle>
<DialogTitle>{t("messages.compose.title")}</DialogTitle>
<DialogDescription>
Start a conversation with a member of your clinic.
{t("messages.compose.description")}
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex flex-col gap-1">
{members.length === 0 ? (
<p className="px-1 py-4 text-center text-muted-foreground text-sm">
No other clinic members yet. Invite colleagues from Settings
Care team.
{t("messages.compose.noMembers")}
</p>
) : (
members.map((m) => (
+26 -14
View File
@@ -2,6 +2,7 @@
import { Plus, Search } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
import { PatientDetailSheet } from "@/components/patients/patient-detail-sheet";
@@ -19,6 +20,7 @@ const statusVariant: Record<Patient["status"], BadgeVariant> = {
};
export function PatientsView() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [addOpen, setAddOpen] = useState(false);
// Bumped on open so the create dialog remounts with a fresh file # / form.
@@ -44,7 +46,7 @@ export function PatientsView() {
.catch((err) => {
if (!active) return;
setLoadError(
err instanceof Error ? err.message : "Failed to load patients."
err instanceof Error ? err.message : t("patients.loadError")
);
})
.finally(() => {
@@ -76,7 +78,9 @@ export function PatientsView() {
return (
<div className="mx-auto w-full max-w-4xl px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-semibold tracking-tight">Patients</h1>
<h1 className="text-2xl font-semibold tracking-tight">
{t("patients.title")}
</h1>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
@@ -90,7 +94,7 @@ export function PatientsView() {
open(patients[0].fileNumber);
}
}}
placeholder="Search name or MRN"
placeholder={t("patients.searchPlaceholder")}
value={query}
/>
</div>
@@ -103,7 +107,7 @@ export function PatientsView() {
type="button"
>
<Plus className="size-4" />
Add patient
{t("patients.add")}
</Button>
</div>
</div>
@@ -112,12 +116,20 @@ export function PatientsView() {
<table className="w-full text-sm">
<thead>
<tr className="border-border border-b text-left text-xs text-muted-foreground uppercase">
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">MRN</th>
<th className="px-4 py-3 font-medium">Age · Sex</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Last seen</th>
<th className="px-4 py-3 font-medium">Allergies</th>
<th className="px-4 py-3 font-medium">{t("patients.columns.name")}</th>
<th className="px-4 py-3 font-medium">{t("patients.columns.mrn")}</th>
<th className="px-4 py-3 font-medium">
{t("patients.columns.ageSex")}
</th>
<th className="px-4 py-3 font-medium">
{t("patients.columns.status")}
</th>
<th className="px-4 py-3 font-medium">
{t("patients.columns.lastSeen")}
</th>
<th className="px-4 py-3 font-medium">
{t("patients.columns.allergies")}
</th>
</tr>
</thead>
<tbody>
@@ -127,7 +139,7 @@ export function PatientsView() {
className="px-4 py-10 text-center text-muted-foreground"
colSpan={6}
>
Loading patients
{t("patients.loading")}
</td>
</tr>
) : loadError ? (
@@ -142,7 +154,7 @@ export function PatientsView() {
className="px-4 py-10 text-center text-muted-foreground"
colSpan={6}
>
No patients found.
{t("patients.empty")}
</td>
</tr>
) : (
@@ -170,8 +182,8 @@ export function PatientsView() {
{p.age} · {p.sex}
</td>
<td className="px-4 py-3">
<Badge className="capitalize" variant={statusVariant[p.status]}>
{p.status}
<Badge variant={statusVariant[p.status]}>
{t(`patients.status.${p.status}`)}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
@@ -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} />
+23 -17
View File
@@ -15,6 +15,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTheme } from "next-themes";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useCommandPalette } from "@/components/command-palette";
import { CreateClinicForm } from "@/components/clinic/create-clinic-form";
@@ -80,6 +81,7 @@ function GitHubIcon({ className }: { className?: string }) {
// shortcut hint (below Theme) and a clinic switcher submenu (below that) whose
// popup reveals the active clinic's details on hover.
export function NavUser() {
const { t } = useTranslation();
const { isMobile, state } = useSidebar();
const isCollapsed = state === "collapsed";
const router = useRouter();
@@ -92,10 +94,10 @@ export function NavUser() {
const [createOpen, setCreateOpen] = useState(false);
const name = data?.user?.name ?? "Clinician";
const name = data?.user?.name ?? t("userMenu.defaultName");
const email = data?.user?.email ?? "";
const initials = initialsFromName(name);
const activeName = activeOrg?.name ?? "Select clinic";
const activeName = activeOrg?.name ?? t("userMenu.selectClinic");
const setActive = async (organizationId: string) => {
if (organizationId === activeOrg?.id) return;
@@ -105,10 +107,13 @@ export function NavUser() {
const signOut = async () => {
const { error } = await authClient.signOut();
if (error) {
notify.error("Sign out failed", error.message ?? "Please try again.");
notify.error(
t("userMenu.signOutFailed"),
error.message ?? t("userMenu.tryAgain"),
);
return;
}
notify.success("Signed out");
notify.success(t("userMenu.signedOut"));
router.push("/login");
};
@@ -162,27 +167,29 @@ export function NavUser() {
<MenuSeparator />
<MenuItem render={<Link href="/settings" />}>
<SettingsIcon />
Settings
{t("userMenu.settings")}
</MenuItem>
<MenuItem
render={<a href={REPO_URL} rel="noreferrer" target="_blank" />}
>
<GitHubIcon className="size-4" />
Docs &amp; GitHub
{t("userMenu.docs")}
</MenuItem>
<MenuItem
closeOnClick={false}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Moon /> : <Sun />}
Theme
<MenuShortcut>{isDark ? "Dark" : "Light"}</MenuShortcut>
{t("userMenu.theme")}
<MenuShortcut>
{isDark ? t("userMenu.dark") : t("userMenu.light")}
</MenuShortcut>
</MenuItem>
{/* Command palette: hint + shortcut, sits below Theme. */}
<MenuItem onClick={openCommand}>
<Search />
Search
{t("userMenu.search")}
<KbdGroup className="ms-auto">
<Kbd></Kbd>
<Kbd>K</Kbd>
@@ -198,18 +205,17 @@ export function NavUser() {
<MenuSubPopup className="min-w-64" sideOffset={8}>
<div className="px-2 py-1.5">
<p className="truncate font-medium text-foreground text-sm">
{activeOrg?.name ?? "No clinic selected"}
{activeOrg?.name ?? t("userMenu.noClinic")}
</p>
<p className="truncate text-muted-foreground text-xs">
{activeOrg?.slug ? `/${activeOrg.slug}` : "—"} ·{" "}
{orgs?.length ?? 0}{" "}
{orgs?.length === 1 ? "clinic" : "clinics"}
{t("userMenu.clinicCount", { count: orgs?.length ?? 0 })}
</p>
</div>
<MenuSeparator />
<MenuGroup>
<MenuGroupLabel className="text-muted-foreground text-xs">
Switch clinic
{t("userMenu.switchClinic")}
</MenuGroupLabel>
{(orgs ?? []).map((org) => (
<MenuItem
@@ -228,7 +234,7 @@ export function NavUser() {
<MenuSeparator />
<MenuItem className="gap-2" onClick={() => setCreateOpen(true)}>
<Plus />
Create clinic
{t("userMenu.createClinic")}
</MenuItem>
</MenuSubPopup>
</MenuSub>
@@ -236,7 +242,7 @@ export function NavUser() {
<MenuSeparator />
<MenuItem onClick={signOut} variant="destructive">
<LogOut />
Log out
{t("userMenu.logout")}
</MenuItem>
</MenuPopup>
</Menu>
@@ -246,9 +252,9 @@ export function NavUser() {
<Dialog onOpenChange={setCreateOpen} open={createOpen}>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create clinic</DialogTitle>
<DialogTitle>{t("userMenu.createDialogTitle")}</DialogTitle>
<DialogDescription>
Add a new clinic and switch to it.
{t("userMenu.createDialogDescription")}
</DialogDescription>
</DialogHeader>
<DialogPanel>
+29 -16
View File
@@ -1,5 +1,7 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -19,15 +21,9 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
low: "outline",
};
const priorityLabel: Record<Priority, string> = {
high: "High",
medium: "Medium",
low: "Low",
};
// Right-side Sheet showing a single task's full detail, opened from the Tasks
// list (mirrors the Patients table → PatientDetailSheet pattern). The task is
// passed in directly since tasks live in local state — no async fetch.
// passed in directly from the page.
export function TaskDetailSheet({
task,
open,
@@ -39,6 +35,7 @@ export function TaskDetailSheet({
onOpenChange: (open: boolean) => void;
onToggle: (id: string) => void;
}) {
const { t } = useTranslation();
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-xl" side="right">
@@ -46,7 +43,7 @@ export function TaskDetailSheet({
<SheetTitle
className={cn(task?.done && "text-muted-foreground line-through")}
>
{task?.title ?? "Task"}
{task?.title ?? t("tasks.detail.fallbackTitle")}
</SheetTitle>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
@@ -54,22 +51,34 @@ export function TaskDetailSheet({
<div className="flex flex-col gap-5">
<div>
<Badge variant={priorityVariant[task.priority]}>
{priorityLabel[task.priority]} priority
{t("tasks.detail.priorityBadge", {
priority: t(`tasks.priority.${task.priority}`),
})}
</Badge>
</div>
<dl className="grid grid-cols-[6rem_1fr] gap-x-3 gap-y-2 text-sm">
<dt className="text-muted-foreground">Status</dt>
<dt className="text-muted-foreground">
{t("tasks.detail.status")}
</dt>
<dd className="text-foreground">
{task.done ? "Completed" : "Open"}
{task.done
? t("tasks.detail.completed")
: t("tasks.detail.open")}
</dd>
<dt className="text-muted-foreground">Assignee</dt>
<dt className="text-muted-foreground">
{t("tasks.detail.assignee")}
</dt>
<dd className="text-foreground">{task.assignee}</dd>
<dt className="text-muted-foreground">Due</dt>
<dt className="text-muted-foreground">
{t("tasks.detail.due")}
</dt>
<dd className="text-foreground">{task.due}</dd>
{task.patient && (
<>
<dt className="text-muted-foreground">Patient</dt>
<dt className="text-muted-foreground">
{t("tasks.detail.patient")}
</dt>
<dd className="text-foreground">{task.patient}</dd>
</>
)}
@@ -77,7 +86,9 @@ export function TaskDetailSheet({
{task.notes && (
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">Details</span>
<span className="text-muted-foreground text-xs">
{t("tasks.detail.details")}
</span>
<p className="whitespace-pre-wrap text-foreground text-sm leading-relaxed">
{task.notes}
</p>
@@ -90,7 +101,9 @@ export function TaskDetailSheet({
type="button"
variant={task.done ? "outline" : "default"}
>
{task.done ? "Reopen task" : "Mark complete"}
{task.done
? t("tasks.detail.reopen")
: t("tasks.detail.complete")}
</Button>
</div>
</div>
+67 -52
View File
@@ -8,6 +8,7 @@ import {
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { TaskDetailSheet } from "@/components/tasks/task-detail-sheet";
import { Badge } from "@/components/ui/badge";
@@ -46,22 +47,18 @@ const priorityVariant: Record<Priority, "destructive" | "secondary" | "outline">
low: "outline",
};
const priorityLabel: Record<Priority, string> = {
high: "High",
medium: "Medium",
low: "Low",
};
function CheckButton({
done,
onClick,
label,
}: {
done: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
aria-label={done ? "Mark as not done" : "Mark as done"}
aria-label={label}
aria-pressed={done}
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-md border transition-colors",
@@ -98,6 +95,7 @@ function AddTaskDialog({
onOpenChange: (open: boolean) => void;
onAdd: (task: TaskInput) => void;
}) {
const { t } = useTranslation();
const [title, setTitle] = useState("");
const [notes, setNotes] = useState("");
const [assignee, setAssignee] = useState("");
@@ -115,7 +113,10 @@ function AddTaskDialog({
const submit = (event: FormEvent) => {
event.preventDefault();
if (!title.trim()) {
notify.error("Add a subject", "Describe the task first.");
notify.error(
t("tasks.toast.needSubjectTitle"),
t("tasks.toast.needSubjectBody"),
);
return;
}
onAdd({
@@ -125,7 +126,7 @@ function AddTaskDialog({
due: due.trim() || "No due date",
priority,
});
notify.success("Task added", title.trim());
notify.success(t("tasks.toast.addedTitle"), title.trim());
reset();
onOpenChange(false);
};
@@ -140,64 +141,62 @@ function AddTaskDialog({
>
<DialogPopup className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New task</DialogTitle>
<DialogDescription>
Assign a follow-up to the care team.
</DialogDescription>
<DialogTitle>{t("tasks.dialog.title")}</DialogTitle>
<DialogDescription>{t("tasks.dialog.description")}</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label="Subject">
<Field label={t("tasks.dialog.subject")}>
<Input
autoFocus
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Review lab results"
placeholder={t("tasks.dialog.subjectPlaceholder")}
value={title}
/>
</Field>
<Field label="Details — what needs to be done">
<Field label={t("tasks.dialog.details")}>
<Textarea
onChange={(e) => setNotes(e.target.value)}
placeholder="Describe what needs to happen, any context, links…"
placeholder={t("tasks.dialog.detailsPlaceholder")}
rows={4}
value={notes}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Assignee">
<Field label={t("tasks.dialog.assignee")}>
<Input
onChange={(e) => setAssignee(e.target.value)}
placeholder="e.g. Dr. Okafor"
placeholder={t("tasks.dialog.assigneePlaceholder")}
value={assignee}
/>
</Field>
<Field label="Due">
<Field label={t("tasks.dialog.due")}>
<Input
onChange={(e) => setDue(e.target.value)}
placeholder="e.g. Today"
placeholder={t("tasks.dialog.duePlaceholder")}
value={due}
/>
</Field>
</div>
<Field label="Priority">
<Field label={t("tasks.dialog.priorityLabel")}>
<select
className={controlClass}
onChange={(e) => setPriority(e.target.value as Priority)}
value={priority}
>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="high">{t("tasks.priority.high")}</option>
<option value="medium">{t("tasks.priority.medium")}</option>
<option value="low">{t("tasks.priority.low")}</option>
</select>
</Field>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
{t("tasks.dialog.cancel")}
</DialogClose>
<Button type="submit">Add task</Button>
<Button type="submit">{t("tasks.dialog.add")}</Button>
</DialogFooter>
</form>
</DialogPopup>
@@ -206,6 +205,7 @@ function AddTaskDialog({
}
export function TasksView() {
const { t } = useTranslation();
const [tasks, setTasks] = useState<Task[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -226,29 +226,32 @@ export function TasksView() {
};
}, []);
const selected = tasks.find((t) => t.id === selectedId) ?? null;
const selected = tasks.find((task) => task.id === selectedId) ?? null;
const visible = useMemo(() => {
if (filter === "open") return tasks.filter((t) => !t.done);
if (filter === "done") return tasks.filter((t) => t.done);
if (filter === "open") return tasks.filter((task) => !task.done);
if (filter === "done") return tasks.filter((task) => task.done);
return tasks;
}, [tasks, filter]);
// Optimistically flip done, then persist; roll back on failure.
const toggle = async (id: string) => {
const current = tasks.find((t) => t.id === id);
const current = tasks.find((task) => task.id === id);
if (!current) return;
const next = !current.done;
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: next } : t)),
prev.map((row) => (row.id === id ? { ...row, done: next } : row)),
);
try {
await updateTask(id, { done: next });
} catch {
setTasks((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: current.done } : t)),
prev.map((row) => (row.id === id ? { ...row, done: current.done } : row)),
);
notify.error(
t("tasks.toast.updateFailedTitle"),
t("tasks.toast.updateFailedBody"),
);
notify.error("Couldn't update task", "Please try again.");
}
};
@@ -262,7 +265,10 @@ export function TasksView() {
const created = await createTask(task);
setTasks((prev) => [created, ...prev]);
} catch {
notify.error("Couldn't add task", "Please try again.");
notify.error(
t("tasks.toast.addFailedTitle"),
t("tasks.toast.addFailedBody"),
);
}
};
@@ -270,10 +276,10 @@ export function TasksView() {
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="font-semibold text-2xl tracking-tight">Tasks</h1>
<p className="text-muted-foreground text-sm">
Care-team to-dos. Click a task to see its details.
</p>
<h1 className="font-semibold text-2xl tracking-tight">
{t("tasks.title")}
</h1>
<p className="text-muted-foreground text-sm">{t("tasks.subtitle")}</p>
</div>
<Button
className="rounded-3xl"
@@ -281,21 +287,21 @@ export function TasksView() {
type="button"
>
<Plus className="size-4" />
New task
{t("tasks.new")}
</Button>
</div>
<div className="flex w-full items-center gap-1 rounded-2xl border bg-card/30 p-1 sm:w-fit">
{(["all", "open", "done"] as Filter[]).map((f) => (
<Button
className="flex-1 capitalize sm:flex-none"
className="flex-1 sm:flex-none"
key={f}
onClick={() => setFilter(f)}
size="sm"
type="button"
variant={filter === f ? "secondary" : "ghost"}
>
{f}
{t(`tasks.filters.${f}`)}
</Button>
))}
</div>
@@ -303,33 +309,42 @@ export function TasksView() {
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{visible.length === 0 ? (
<p className="px-4 py-10 text-center text-muted-foreground text-sm">
No tasks here.
{t("tasks.empty")}
</p>
) : (
visible.map((t) => (
<div className="flex items-center gap-3 px-4 py-3" key={t.id}>
<CheckButton done={t.done} onClick={() => toggle(t.id)} />
visible.map((task) => (
<div className="flex items-center gap-3 px-4 py-3" key={task.id}>
<CheckButton
done={task.done}
label={
task.done ? t("tasks.markNotDone") : t("tasks.markDone")
}
onClick={() => toggle(task.id)}
/>
<button
className="flex min-w-0 flex-1 flex-col text-left"
onClick={() => openTask(t.id)}
onClick={() => openTask(task.id)}
type="button"
>
<span
className={cn(
"truncate text-sm",
t.done
task.done
? "text-muted-foreground line-through"
: "font-medium text-foreground",
)}
>
{t.title}
{task.title}
</span>
<span className="truncate text-muted-foreground text-xs">
{t.assignee} · {t.due}
{task.assignee} · {task.due}
</span>
</button>
<Badge className="shrink-0" variant={priorityVariant[t.priority]}>
{priorityLabel[t.priority]}
<Badge
className="shrink-0"
variant={priorityVariant[task.priority]}
>
{t(`tasks.priority.${task.priority}`)}
</Badge>
</div>
))
+339 -1
View File
@@ -4,7 +4,8 @@
"email": "Email",
"password": "Password",
"signIn": "Sign in",
"signUp": "Sign up"
"signUp": "Sign up",
"backToSignIn": "Back to sign in"
},
"auth": {
"login": {
@@ -42,6 +43,65 @@
"errorTitle": "Sign up failed",
"created": "Account created",
"createdHint": "Set up your clinic to get started."
},
"verifyEmail": {
"title": "Check your inbox",
"subtitle": "We sent a verification link to {{email}}. Open it to activate your account.",
"subtitleNoEmail": "Open the verification link we emailed you to activate your account.",
"continue": "I've verified — continue",
"resend": "Resend email",
"resending": "Sending…",
"resent": "Verification email sent. Check your inbox.",
"resendError": "Could not resend the verification email."
},
"forgotPassword": {
"title": "Reset your password",
"subtitle": "We'll email you a link to reset your password",
"emailLabel": "Email",
"emailPlaceholder": "you@clinic.org",
"submit": "Send reset link",
"submitting": "Sending…",
"sent": "If an account exists for {{email}}, a reset link is on its way. Check your inbox.",
"error": "Could not send the reset email.",
"failedToastTitle": "Couldn't send reset link",
"sentToastTitle": "Reset link sent",
"sentToastBody": "Check your inbox for the link."
},
"resetPassword": {
"title": "Set a new password",
"subtitle": "Choose a new password for your account",
"passwordLabel": "New password",
"confirmLabel": "Confirm new password",
"passwordHint": "At least {{count}} characters.",
"submit": "Reset password",
"submitting": "Saving…",
"done": "Your password has been reset. Redirecting you to sign in…",
"invalidToken": "This reset link is invalid or has expired.",
"tooShort": "Password must be at least {{count}} characters.",
"mismatch": "Passwords do not match.",
"error": "Could not reset your password.",
"failedToastTitle": "Couldn't reset password",
"successToastTitle": "Password updated",
"successToastBody": "Redirecting you to sign in…"
},
"acceptInvite": {
"notFoundTitle": "Invitation not found",
"notFoundBody": "This invitation link is missing or invalid.",
"invitedTitle": "You've been invited",
"invitedSubtitle": "Sign in with the email this invitation was sent to, then return to this link to accept.",
"signIn": "Sign in",
"createAccount": "Create an account",
"reopenPrefix": "After signing in, reopen",
"reopenLink": "this invitation link",
"acceptTitle": "Accept your invitation",
"acceptSubtitle": "Join the clinic you were invited to on temetro.",
"accept": "Accept invitation",
"accepting": "Joining…",
"error": "This invitation is invalid or has expired."
},
"onboarding": {
"title": "Set up your clinic",
"subtitle": "Create your clinic to start organizing patient records"
}
},
"nav": {
@@ -65,6 +125,284 @@
"commandOpen": "Open",
"commandClose": "Close"
},
"userMenu": {
"defaultName": "Clinician",
"selectClinic": "Select clinic",
"settings": "Settings",
"docs": "Docs & GitHub",
"theme": "Theme",
"dark": "Dark",
"light": "Light",
"search": "Search",
"noClinic": "No clinic selected",
"clinicCount_one": "{{count}} clinic",
"clinicCount_other": "{{count}} clinics",
"switchClinic": "Switch clinic",
"createClinic": "Create clinic",
"logout": "Log out",
"createDialogTitle": "Create clinic",
"createDialogDescription": "Add a new clinic and switch to it.",
"signOutFailed": "Sign out failed",
"tryAgain": "Please try again.",
"signedOut": "Signed out"
},
"clinic": {
"create": "Create clinic",
"creating": "Creating clinic…",
"nameLabel": "Clinic name",
"namePlaceholder": "North Side Family Practice",
"slugLabel": "Clinic URL slug",
"slugPlaceholder": "north-side-family-practice",
"slugHint": "Used in links and invitations. Lowercase letters, numbers and dashes.",
"createError": "Could not create the clinic.",
"createFailedTitle": "Couldn't create clinic",
"createdTitle": "Clinic created",
"createdBody": "{{name}} is ready."
},
"patients": {
"title": "Patients",
"searchPlaceholder": "Search name or MRN",
"add": "Add patient",
"loading": "Loading patients…",
"empty": "No patients found.",
"loadError": "Failed to load patients.",
"columns": {
"name": "Name",
"mrn": "MRN",
"ageSex": "Age · Sex",
"status": "Status",
"lastSeen": "Last seen",
"allergies": "Allergies"
},
"status": {
"active": "Active",
"inpatient": "Inpatient",
"discharged": "Discharged"
}
},
"appointments": {
"title": "Appointments & Schedule",
"subtitle": "Today's clinic schedule and what's coming up.",
"searchPlaceholder": "Search patient, type, provider",
"calendar": "Calendar",
"add": "Add",
"noMatches": "No matching appointments.",
"today": "Today",
"nothingToday": "Nothing scheduled today.",
"addFailedTitle": "Couldn't add appointment",
"addFailedBody": "Please try again.",
"kpi": {
"today": "Today",
"thisWeek": "This week",
"checkedIn": "Checked in",
"completed": "Completed"
},
"status": {
"confirmed": "Confirmed",
"checked-in": "Checked in",
"completed": "Completed",
"cancelled": "Cancelled"
},
"dialog": {
"title": "New appointment",
"description": "Search for a patient by name or file number, then set the slot.",
"patient": "Patient",
"fileNumber": "File #{{number}}",
"change": "Change",
"searchPlaceholder": "Search name or file number",
"noPatients": "No patients found.",
"date": "Date",
"time": "Time",
"type": "Type",
"provider": "Provider",
"providerPlaceholder": "e.g. Dr. Okafor",
"cancel": "Cancel",
"submit": "Add appointment",
"pickPatientTitle": "Pick a patient",
"pickPatientBody": "Search and select a patient first.",
"addedTitle": "Appointment added"
},
"calendarDialog": {
"today": "Today",
"appointmentCount_one": "{{count}} appointment",
"appointmentCount_other": "{{count}} appointments",
"none": "No appointments on this day."
}
},
"prescriptions": {
"title": "Prescriptions",
"subtitle": "Medications prescribed across the clinic.",
"new": "New prescription",
"recent": "Recent prescriptions",
"recentDescription": "Most recent first",
"addFailedTitle": "Couldn't add prescription",
"addFailedBody": "Please try again.",
"kpi": {
"active": "Active",
"completed": "Completed",
"expired": "Expired"
},
"status": {
"active": "Active",
"completed": "Completed",
"expired": "Expired"
},
"detail": {
"fallbackTitle": "Prescription",
"fileNumber": "File #{{number}}",
"medication": "Medication",
"dose": "Dose",
"frequency": "Frequency",
"duration": "Duration",
"prescriber": "Prescriber",
"date": "Date",
"status": "Status",
"notes": "Notes"
},
"dialog": {
"title": "New prescription",
"description": "Search for a patient by name or file number, then set the medication.",
"patient": "Patient",
"fileNumber": "File #{{number}}",
"change": "Change",
"searchPlaceholder": "Search name or file number",
"noPatients": "No patients found.",
"medication": "Medication",
"medicationPlaceholder": "e.g. Amoxicillin",
"dose": "Dose",
"dosePlaceholder": "e.g. 500 mg",
"frequency": "Frequency",
"duration": "Duration",
"durationOtherPlaceholder": "e.g. 21 days",
"notes": "Notes",
"notesPlaceholder": "Instructions, e.g. take with food in the morning",
"interactionTitle": "Possible interaction",
"cancel": "Cancel",
"submit": "Add prescription",
"pickPatientTitle": "Pick a patient",
"pickPatientBody": "Search and select a patient first.",
"needMedTitle": "Add a medication",
"needMedBody": "Enter the medication name.",
"needDurationTitle": "Add a duration",
"needDurationBody": "Enter the custom duration.",
"addedTitle": "Prescription added"
}
},
"tasks": {
"title": "Tasks",
"subtitle": "Care-team to-dos. Click a task to see its details.",
"new": "New task",
"empty": "No tasks here.",
"markDone": "Mark as done",
"markNotDone": "Mark as not done",
"filters": {
"all": "All",
"open": "Open",
"done": "Done"
},
"priority": {
"high": "High",
"medium": "Medium",
"low": "Low"
},
"dialog": {
"title": "New task",
"description": "Assign a follow-up to the care team.",
"subject": "Subject",
"subjectPlaceholder": "e.g. Review lab results",
"details": "Details — what needs to be done",
"detailsPlaceholder": "Describe what needs to happen, any context, links…",
"assignee": "Assignee",
"assigneePlaceholder": "e.g. Dr. Okafor",
"due": "Due",
"duePlaceholder": "e.g. Today",
"priorityLabel": "Priority",
"cancel": "Cancel",
"add": "Add task"
},
"detail": {
"fallbackTitle": "Task",
"priorityBadge": "{{priority}} priority",
"status": "Status",
"completed": "Completed",
"open": "Open",
"assignee": "Assignee",
"due": "Due",
"patient": "Patient",
"details": "Details",
"reopen": "Reopen task",
"complete": "Mark complete"
},
"toast": {
"needSubjectTitle": "Add a subject",
"needSubjectBody": "Describe the task first.",
"addedTitle": "Task added",
"addFailedTitle": "Couldn't add task",
"addFailedBody": "Please try again.",
"updateFailedTitle": "Couldn't update task",
"updateFailedBody": "Please try again."
}
},
"messages": {
"inbox": "Inbox",
"unread": "Unread · {{count}}",
"newMessage": "New message",
"noUnread": "No unread messages.",
"noConversations": "No conversations yet.",
"you": "You: ",
"directMessage": "Direct message",
"peopleCount": "{{count}} people",
"messagePlaceholder": "Message {{name}}…",
"send": "Send",
"emptyTitle": "No conversation selected",
"emptyDescription": "Choose a conversation from the inbox, or start a new one.",
"compose": {
"title": "New message",
"description": "Start a conversation with a member of your clinic.",
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
},
"startFailedTitle": "Couldn't start conversation",
"startFailedBody": "Please try again."
},
"analysis": {
"title": "Analysis",
"subtitle": "Clinic performance at a glance, computed from your clinic's data.",
"patientVolume": {
"title": "Patient volume",
"description": "New, active and total patients",
"total": "Total patients",
"newThisMonth": "New this month",
"active": "Active patients"
},
"appointments": {
"title": "Appointments & schedule",
"description": "Bookings, attendance and what's coming up",
"thisWeek": "This week",
"upcoming": "Upcoming",
"completed": "Completed",
"cancelled": "Cancelled"
},
"prescriptions": {
"title": "Prescriptions",
"description": "Medications prescribed",
"total": "Total issued",
"active": "Active"
},
"tasks": {
"title": "Tasks",
"description": "Care-team workload",
"open": "Open",
"completed": "Completed"
}
},
"activity": {
"title": "Activity",
"subtitle": "An audit log of record changes across the clinic.",
"changesToday": "Changes today",
"thisWeek": "This week",
"totalRecorded": "Total recorded",
"empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here."
},
"settings": {
"tabs": {
"profile": "Profile",