mirror of
https://github.com/temetro/temetro.git
synced 2026-08-11 11:09:04 +00:00
i18n: convert feature pages, dialogs, auth sub-pages and nav to t()
Move all hardcoded UI strings in the appointments, prescriptions, tasks, messages, activity, analysis and patients views (plus their dialogs and detail sheets), the remaining auth pages (verify-email, forgot/reset password, accept-invite, onboarding), the clinic form and the sidebar user menu into i18next keys in en/translation.json. Clinical option values (appointment types, frequencies) stay canonical. English-only; no new locale yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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'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 & Schedule
|
||||
{t("appointments.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Today's clinic schedule and what'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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user