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
+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>
))