mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +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:
@@ -3,12 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthShell, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
function AcceptInviteInner() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const invitationId = params.get("id") ?? "";
|
||||
@@ -24,7 +26,7 @@ function AcceptInviteInner() {
|
||||
invitationId,
|
||||
});
|
||||
if (err || !data) {
|
||||
setError(err?.message ?? "This invitation is invalid or has expired.");
|
||||
setError(err?.message ?? t("auth.acceptInvite.error"));
|
||||
setAccepting(false);
|
||||
return;
|
||||
}
|
||||
@@ -37,8 +39,8 @@ function AcceptInviteInner() {
|
||||
|
||||
if (!invitationId) {
|
||||
return (
|
||||
<AuthShell title="Invitation not found">
|
||||
<FormAlert>This invitation link is missing or invalid.</FormAlert>
|
||||
<AuthShell title={t("auth.acceptInvite.notFoundTitle")}>
|
||||
<FormAlert>{t("auth.acceptInvite.notFoundBody")}</FormAlert>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -48,8 +50,8 @@ function AcceptInviteInner() {
|
||||
const back = `/accept-invite?id=${encodeURIComponent(invitationId)}`;
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Sign in with the email this invitation was sent to, then return to this link to accept."
|
||||
title="You've been invited"
|
||||
subtitle={t("auth.acceptInvite.invitedSubtitle")}
|
||||
title={t("auth.acceptInvite.invitedTitle")}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Button
|
||||
@@ -57,7 +59,7 @@ function AcceptInviteInner() {
|
||||
onClick={() => router.push("/login")}
|
||||
type="button"
|
||||
>
|
||||
Sign in
|
||||
{t("auth.acceptInvite.signIn")}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full"
|
||||
@@ -65,12 +67,12 @@ function AcceptInviteInner() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Create an account
|
||||
{t("auth.acceptInvite.createAccount")}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
After signing in, reopen{" "}
|
||||
{t("auth.acceptInvite.reopenPrefix")}{" "}
|
||||
<Link className="hover:underline" href={back}>
|
||||
this invitation link
|
||||
{t("auth.acceptInvite.reopenLink")}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
@@ -81,8 +83,8 @@ function AcceptInviteInner() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Join the clinic you were invited to on temetro."
|
||||
title="Accept your invitation"
|
||||
subtitle={t("auth.acceptInvite.acceptSubtitle")}
|
||||
title={t("auth.acceptInvite.acceptTitle")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
@@ -92,7 +94,9 @@ function AcceptInviteInner() {
|
||||
onClick={accept}
|
||||
type="button"
|
||||
>
|
||||
{accepting ? "Joining…" : "Accept invitation"}
|
||||
{accepting
|
||||
? t("auth.acceptInvite.accepting")
|
||||
: t("auth.acceptInvite.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthShell>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -10,6 +11,7 @@ import { authClient } from "@/lib/auth-client";
|
||||
import { notify } from "@/lib/toast";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -28,12 +30,15 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
const message = err.message ?? "Could not send the reset email.";
|
||||
const message = err.message ?? t("auth.forgotPassword.error");
|
||||
setError(message);
|
||||
notify.error("Couldn't send reset link", message);
|
||||
notify.error(t("auth.forgotPassword.failedToastTitle"), message);
|
||||
return;
|
||||
}
|
||||
notify.success("Reset link sent", "Check your inbox for the link.");
|
||||
notify.success(
|
||||
t("auth.forgotPassword.sentToastTitle"),
|
||||
t("auth.forgotPassword.sentToastBody"),
|
||||
);
|
||||
setSent(true);
|
||||
};
|
||||
|
||||
@@ -41,33 +46,34 @@ export default function ForgotPasswordPage() {
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
{t("common.backToSignIn")}
|
||||
</Link>
|
||||
}
|
||||
subtitle="We'll email you a link to reset your password"
|
||||
title="Reset your password"
|
||||
subtitle={t("auth.forgotPassword.subtitle")}
|
||||
title={t("auth.forgotPassword.title")}
|
||||
>
|
||||
{sent ? (
|
||||
<FormAlert tone="success">
|
||||
If an account exists for {email}, a reset link is on its way. Check
|
||||
your inbox.
|
||||
{t("auth.forgotPassword.sent", { email })}
|
||||
</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field htmlFor="email" label="Email">
|
||||
<Field htmlFor="email" label={t("auth.forgotPassword.emailLabel")}>
|
||||
<Input
|
||||
autoComplete="email"
|
||||
id="email"
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="you@clinic.org"
|
||||
placeholder={t("auth.forgotPassword.emailPlaceholder")}
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Sending…" : "Send reset link"}
|
||||
{submitting
|
||||
? t("auth.forgotPassword.submitting")
|
||||
: t("auth.forgotPassword.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthShell } from "@/components/auth/auth-ui";
|
||||
import { CreateClinicForm } from "@/components/clinic/create-clinic-form";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
@@ -20,8 +22,8 @@ export default function OnboardingPage() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
subtitle="Create your clinic to start organizing patient records"
|
||||
title="Set up your clinic"
|
||||
subtitle={t("auth.onboarding.subtitle")}
|
||||
title={t("auth.onboarding.title")}
|
||||
>
|
||||
<CreateClinicForm onCreated={() => router.push("/")} />
|
||||
</AuthShell>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { type FormEvent, Suspense, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthShell, Field, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +14,7 @@ import { notify } from "@/lib/toast";
|
||||
const MIN_PASSWORD = 12;
|
||||
|
||||
function ResetPasswordInner() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const token = params.get("token") ?? "";
|
||||
@@ -28,15 +30,15 @@ function ResetPasswordInner() {
|
||||
setError(null);
|
||||
|
||||
if (!token) {
|
||||
setError("This reset link is invalid or has expired.");
|
||||
setError(t("auth.resetPassword.invalidToken"));
|
||||
return;
|
||||
}
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setError(`Password must be at least ${MIN_PASSWORD} characters.`);
|
||||
setError(t("auth.resetPassword.tooShort", { count: MIN_PASSWORD }));
|
||||
return;
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError("Passwords do not match.");
|
||||
setError(t("auth.resetPassword.mismatch"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,12 +49,15 @@ function ResetPasswordInner() {
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (err) {
|
||||
const message = err.message ?? "Could not reset your password.";
|
||||
const message = err.message ?? t("auth.resetPassword.error");
|
||||
setError(message);
|
||||
notify.error("Couldn't reset password", message);
|
||||
notify.error(t("auth.resetPassword.failedToastTitle"), message);
|
||||
return;
|
||||
}
|
||||
notify.success("Password updated", "Redirecting you to sign in…");
|
||||
notify.success(
|
||||
t("auth.resetPassword.successToastTitle"),
|
||||
t("auth.resetPassword.successToastBody"),
|
||||
);
|
||||
setDone(true);
|
||||
setTimeout(() => router.push("/login"), 1500);
|
||||
};
|
||||
@@ -61,23 +66,21 @@ function ResetPasswordInner() {
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
{t("common.backToSignIn")}
|
||||
</Link>
|
||||
}
|
||||
subtitle="Choose a new password for your account"
|
||||
title="Set a new password"
|
||||
subtitle={t("auth.resetPassword.subtitle")}
|
||||
title={t("auth.resetPassword.title")}
|
||||
>
|
||||
{done ? (
|
||||
<FormAlert tone="success">
|
||||
Your password has been reset. Redirecting you to sign in…
|
||||
</FormAlert>
|
||||
<FormAlert tone="success">{t("auth.resetPassword.done")}</FormAlert>
|
||||
) : (
|
||||
<form className="flex flex-col gap-4" onSubmit={onSubmit}>
|
||||
{error && <FormAlert>{error}</FormAlert>}
|
||||
<Field
|
||||
hint={`At least ${MIN_PASSWORD} characters.`}
|
||||
hint={t("auth.resetPassword.passwordHint", { count: MIN_PASSWORD })}
|
||||
htmlFor="password"
|
||||
label="New password"
|
||||
label={t("auth.resetPassword.passwordLabel")}
|
||||
>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
@@ -89,7 +92,10 @@ function ResetPasswordInner() {
|
||||
value={password}
|
||||
/>
|
||||
</Field>
|
||||
<Field htmlFor="confirm" label="Confirm new password">
|
||||
<Field
|
||||
htmlFor="confirm"
|
||||
label={t("auth.resetPassword.confirmLabel")}
|
||||
>
|
||||
<Input
|
||||
autoComplete="new-password"
|
||||
id="confirm"
|
||||
@@ -101,7 +107,9 @@ function ResetPasswordInner() {
|
||||
/>
|
||||
</Field>
|
||||
<Button className="mt-1 w-full" disabled={submitting} type="submit">
|
||||
{submitting ? "Saving…" : "Reset password"}
|
||||
{submitting
|
||||
? t("auth.resetPassword.submitting")
|
||||
: t("auth.resetPassword.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AuthShell, FormAlert } from "@/components/auth/auth-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
function VerifyEmailInner() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const email = params.get("email") ?? "";
|
||||
@@ -34,31 +36,25 @@ function VerifyEmailInner() {
|
||||
});
|
||||
setSending(false);
|
||||
if (err) {
|
||||
setError(err.message ?? "Could not resend the verification email.");
|
||||
setError(err.message ?? t("auth.verifyEmail.resendError"));
|
||||
return;
|
||||
}
|
||||
setNotice("Verification email sent. Check your inbox.");
|
||||
setNotice(t("auth.verifyEmail.resent"));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
footer={
|
||||
<Link className="text-foreground hover:underline" href="/login">
|
||||
Back to sign in
|
||||
{t("common.backToSignIn")}
|
||||
</Link>
|
||||
}
|
||||
subtitle={
|
||||
email ? (
|
||||
<>
|
||||
We sent a verification link to{" "}
|
||||
<span className="text-foreground">{email}</span>. Open it to activate
|
||||
your account.
|
||||
</>
|
||||
) : (
|
||||
"Open the verification link we emailed you to activate your account."
|
||||
)
|
||||
email
|
||||
? t("auth.verifyEmail.subtitle", { email })
|
||||
: t("auth.verifyEmail.subtitleNoEmail")
|
||||
}
|
||||
title="Check your inbox"
|
||||
title={t("auth.verifyEmail.title")}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && <FormAlert tone="success">{notice}</FormAlert>}
|
||||
@@ -68,7 +64,7 @@ function VerifyEmailInner() {
|
||||
onClick={() => router.push("/")}
|
||||
type="button"
|
||||
>
|
||||
I've verified — continue
|
||||
{t("auth.verifyEmail.continue")}
|
||||
</Button>
|
||||
{email && (
|
||||
<Button
|
||||
@@ -78,7 +74,7 @@ function VerifyEmailInner() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{sending ? "Sending…" : "Resend email"}
|
||||
{sending ? t("auth.verifyEmail.resending") : t("auth.verifyEmail.resend")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"signIn": "Sign in",
|
||||
"signUp": "Sign up"
|
||||
"signUp": "Sign up",
|
||||
"backToSignIn": "Back to sign in"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
@@ -42,6 +43,65 @@
|
||||
"errorTitle": "Sign up failed",
|
||||
"created": "Account created",
|
||||
"createdHint": "Set up your clinic to get started."
|
||||
},
|
||||
"verifyEmail": {
|
||||
"title": "Check your inbox",
|
||||
"subtitle": "We sent a verification link to {{email}}. Open it to activate your account.",
|
||||
"subtitleNoEmail": "Open the verification link we emailed you to activate your account.",
|
||||
"continue": "I've verified — continue",
|
||||
"resend": "Resend email",
|
||||
"resending": "Sending…",
|
||||
"resent": "Verification email sent. Check your inbox.",
|
||||
"resendError": "Could not resend the verification email."
|
||||
},
|
||||
"forgotPassword": {
|
||||
"title": "Reset your password",
|
||||
"subtitle": "We'll email you a link to reset your password",
|
||||
"emailLabel": "Email",
|
||||
"emailPlaceholder": "you@clinic.org",
|
||||
"submit": "Send reset link",
|
||||
"submitting": "Sending…",
|
||||
"sent": "If an account exists for {{email}}, a reset link is on its way. Check your inbox.",
|
||||
"error": "Could not send the reset email.",
|
||||
"failedToastTitle": "Couldn't send reset link",
|
||||
"sentToastTitle": "Reset link sent",
|
||||
"sentToastBody": "Check your inbox for the link."
|
||||
},
|
||||
"resetPassword": {
|
||||
"title": "Set a new password",
|
||||
"subtitle": "Choose a new password for your account",
|
||||
"passwordLabel": "New password",
|
||||
"confirmLabel": "Confirm new password",
|
||||
"passwordHint": "At least {{count}} characters.",
|
||||
"submit": "Reset password",
|
||||
"submitting": "Saving…",
|
||||
"done": "Your password has been reset. Redirecting you to sign in…",
|
||||
"invalidToken": "This reset link is invalid or has expired.",
|
||||
"tooShort": "Password must be at least {{count}} characters.",
|
||||
"mismatch": "Passwords do not match.",
|
||||
"error": "Could not reset your password.",
|
||||
"failedToastTitle": "Couldn't reset password",
|
||||
"successToastTitle": "Password updated",
|
||||
"successToastBody": "Redirecting you to sign in…"
|
||||
},
|
||||
"acceptInvite": {
|
||||
"notFoundTitle": "Invitation not found",
|
||||
"notFoundBody": "This invitation link is missing or invalid.",
|
||||
"invitedTitle": "You've been invited",
|
||||
"invitedSubtitle": "Sign in with the email this invitation was sent to, then return to this link to accept.",
|
||||
"signIn": "Sign in",
|
||||
"createAccount": "Create an account",
|
||||
"reopenPrefix": "After signing in, reopen",
|
||||
"reopenLink": "this invitation link",
|
||||
"acceptTitle": "Accept your invitation",
|
||||
"acceptSubtitle": "Join the clinic you were invited to on temetro.",
|
||||
"accept": "Accept invitation",
|
||||
"accepting": "Joining…",
|
||||
"error": "This invitation is invalid or has expired."
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Set up your clinic",
|
||||
"subtitle": "Create your clinic to start organizing patient records"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
@@ -65,6 +125,284 @@
|
||||
"commandOpen": "Open",
|
||||
"commandClose": "Close"
|
||||
},
|
||||
"userMenu": {
|
||||
"defaultName": "Clinician",
|
||||
"selectClinic": "Select clinic",
|
||||
"settings": "Settings",
|
||||
"docs": "Docs & GitHub",
|
||||
"theme": "Theme",
|
||||
"dark": "Dark",
|
||||
"light": "Light",
|
||||
"search": "Search",
|
||||
"noClinic": "No clinic selected",
|
||||
"clinicCount_one": "{{count}} clinic",
|
||||
"clinicCount_other": "{{count}} clinics",
|
||||
"switchClinic": "Switch clinic",
|
||||
"createClinic": "Create clinic",
|
||||
"logout": "Log out",
|
||||
"createDialogTitle": "Create clinic",
|
||||
"createDialogDescription": "Add a new clinic and switch to it.",
|
||||
"signOutFailed": "Sign out failed",
|
||||
"tryAgain": "Please try again.",
|
||||
"signedOut": "Signed out"
|
||||
},
|
||||
"clinic": {
|
||||
"create": "Create clinic",
|
||||
"creating": "Creating clinic…",
|
||||
"nameLabel": "Clinic name",
|
||||
"namePlaceholder": "North Side Family Practice",
|
||||
"slugLabel": "Clinic URL slug",
|
||||
"slugPlaceholder": "north-side-family-practice",
|
||||
"slugHint": "Used in links and invitations. Lowercase letters, numbers and dashes.",
|
||||
"createError": "Could not create the clinic.",
|
||||
"createFailedTitle": "Couldn't create clinic",
|
||||
"createdTitle": "Clinic created",
|
||||
"createdBody": "{{name}} is ready."
|
||||
},
|
||||
"patients": {
|
||||
"title": "Patients",
|
||||
"searchPlaceholder": "Search name or MRN",
|
||||
"add": "Add patient",
|
||||
"loading": "Loading patients…",
|
||||
"empty": "No patients found.",
|
||||
"loadError": "Failed to load patients.",
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"mrn": "MRN",
|
||||
"ageSex": "Age · Sex",
|
||||
"status": "Status",
|
||||
"lastSeen": "Last seen",
|
||||
"allergies": "Allergies"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inpatient": "Inpatient",
|
||||
"discharged": "Discharged"
|
||||
}
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments & Schedule",
|
||||
"subtitle": "Today's clinic schedule and what's coming up.",
|
||||
"searchPlaceholder": "Search patient, type, provider",
|
||||
"calendar": "Calendar",
|
||||
"add": "Add",
|
||||
"noMatches": "No matching appointments.",
|
||||
"today": "Today",
|
||||
"nothingToday": "Nothing scheduled today.",
|
||||
"addFailedTitle": "Couldn't add appointment",
|
||||
"addFailedBody": "Please try again.",
|
||||
"kpi": {
|
||||
"today": "Today",
|
||||
"thisWeek": "This week",
|
||||
"checkedIn": "Checked in",
|
||||
"completed": "Completed"
|
||||
},
|
||||
"status": {
|
||||
"confirmed": "Confirmed",
|
||||
"checked-in": "Checked in",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "New appointment",
|
||||
"description": "Search for a patient by name or file number, then set the slot.",
|
||||
"patient": "Patient",
|
||||
"fileNumber": "File #{{number}}",
|
||||
"change": "Change",
|
||||
"searchPlaceholder": "Search name or file number",
|
||||
"noPatients": "No patients found.",
|
||||
"date": "Date",
|
||||
"time": "Time",
|
||||
"type": "Type",
|
||||
"provider": "Provider",
|
||||
"providerPlaceholder": "e.g. Dr. Okafor",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add appointment",
|
||||
"pickPatientTitle": "Pick a patient",
|
||||
"pickPatientBody": "Search and select a patient first.",
|
||||
"addedTitle": "Appointment added"
|
||||
},
|
||||
"calendarDialog": {
|
||||
"today": "Today",
|
||||
"appointmentCount_one": "{{count}} appointment",
|
||||
"appointmentCount_other": "{{count}} appointments",
|
||||
"none": "No appointments on this day."
|
||||
}
|
||||
},
|
||||
"prescriptions": {
|
||||
"title": "Prescriptions",
|
||||
"subtitle": "Medications prescribed across the clinic.",
|
||||
"new": "New prescription",
|
||||
"recent": "Recent prescriptions",
|
||||
"recentDescription": "Most recent first",
|
||||
"addFailedTitle": "Couldn't add prescription",
|
||||
"addFailedBody": "Please try again.",
|
||||
"kpi": {
|
||||
"active": "Active",
|
||||
"completed": "Completed",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"completed": "Completed",
|
||||
"expired": "Expired"
|
||||
},
|
||||
"detail": {
|
||||
"fallbackTitle": "Prescription",
|
||||
"fileNumber": "File #{{number}}",
|
||||
"medication": "Medication",
|
||||
"dose": "Dose",
|
||||
"frequency": "Frequency",
|
||||
"duration": "Duration",
|
||||
"prescriber": "Prescriber",
|
||||
"date": "Date",
|
||||
"status": "Status",
|
||||
"notes": "Notes"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "New prescription",
|
||||
"description": "Search for a patient by name or file number, then set the medication.",
|
||||
"patient": "Patient",
|
||||
"fileNumber": "File #{{number}}",
|
||||
"change": "Change",
|
||||
"searchPlaceholder": "Search name or file number",
|
||||
"noPatients": "No patients found.",
|
||||
"medication": "Medication",
|
||||
"medicationPlaceholder": "e.g. Amoxicillin",
|
||||
"dose": "Dose",
|
||||
"dosePlaceholder": "e.g. 500 mg",
|
||||
"frequency": "Frequency",
|
||||
"duration": "Duration",
|
||||
"durationOtherPlaceholder": "e.g. 21 days",
|
||||
"notes": "Notes",
|
||||
"notesPlaceholder": "Instructions, e.g. take with food in the morning",
|
||||
"interactionTitle": "Possible interaction",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Add prescription",
|
||||
"pickPatientTitle": "Pick a patient",
|
||||
"pickPatientBody": "Search and select a patient first.",
|
||||
"needMedTitle": "Add a medication",
|
||||
"needMedBody": "Enter the medication name.",
|
||||
"needDurationTitle": "Add a duration",
|
||||
"needDurationBody": "Enter the custom duration.",
|
||||
"addedTitle": "Prescription added"
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
"subtitle": "Care-team to-dos. Click a task to see its details.",
|
||||
"new": "New task",
|
||||
"empty": "No tasks here.",
|
||||
"markDone": "Mark as done",
|
||||
"markNotDone": "Mark as not done",
|
||||
"filters": {
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
"done": "Done"
|
||||
},
|
||||
"priority": {
|
||||
"high": "High",
|
||||
"medium": "Medium",
|
||||
"low": "Low"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "New task",
|
||||
"description": "Assign a follow-up to the care team.",
|
||||
"subject": "Subject",
|
||||
"subjectPlaceholder": "e.g. Review lab results",
|
||||
"details": "Details — what needs to be done",
|
||||
"detailsPlaceholder": "Describe what needs to happen, any context, links…",
|
||||
"assignee": "Assignee",
|
||||
"assigneePlaceholder": "e.g. Dr. Okafor",
|
||||
"due": "Due",
|
||||
"duePlaceholder": "e.g. Today",
|
||||
"priorityLabel": "Priority",
|
||||
"cancel": "Cancel",
|
||||
"add": "Add task"
|
||||
},
|
||||
"detail": {
|
||||
"fallbackTitle": "Task",
|
||||
"priorityBadge": "{{priority}} priority",
|
||||
"status": "Status",
|
||||
"completed": "Completed",
|
||||
"open": "Open",
|
||||
"assignee": "Assignee",
|
||||
"due": "Due",
|
||||
"patient": "Patient",
|
||||
"details": "Details",
|
||||
"reopen": "Reopen task",
|
||||
"complete": "Mark complete"
|
||||
},
|
||||
"toast": {
|
||||
"needSubjectTitle": "Add a subject",
|
||||
"needSubjectBody": "Describe the task first.",
|
||||
"addedTitle": "Task added",
|
||||
"addFailedTitle": "Couldn't add task",
|
||||
"addFailedBody": "Please try again.",
|
||||
"updateFailedTitle": "Couldn't update task",
|
||||
"updateFailedBody": "Please try again."
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"inbox": "Inbox",
|
||||
"unread": "Unread · {{count}}",
|
||||
"newMessage": "New message",
|
||||
"noUnread": "No unread messages.",
|
||||
"noConversations": "No conversations yet.",
|
||||
"you": "You: ",
|
||||
"directMessage": "Direct message",
|
||||
"peopleCount": "{{count}} people",
|
||||
"messagePlaceholder": "Message {{name}}…",
|
||||
"send": "Send",
|
||||
"emptyTitle": "No conversation selected",
|
||||
"emptyDescription": "Choose a conversation from the inbox, or start a new one.",
|
||||
"compose": {
|
||||
"title": "New message",
|
||||
"description": "Start a conversation with a member of your clinic.",
|
||||
"noMembers": "No other clinic members yet. Invite colleagues from Settings → Care team."
|
||||
},
|
||||
"startFailedTitle": "Couldn't start conversation",
|
||||
"startFailedBody": "Please try again."
|
||||
},
|
||||
"analysis": {
|
||||
"title": "Analysis",
|
||||
"subtitle": "Clinic performance at a glance, computed from your clinic's data.",
|
||||
"patientVolume": {
|
||||
"title": "Patient volume",
|
||||
"description": "New, active and total patients",
|
||||
"total": "Total patients",
|
||||
"newThisMonth": "New this month",
|
||||
"active": "Active patients"
|
||||
},
|
||||
"appointments": {
|
||||
"title": "Appointments & schedule",
|
||||
"description": "Bookings, attendance and what's coming up",
|
||||
"thisWeek": "This week",
|
||||
"upcoming": "Upcoming",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"prescriptions": {
|
||||
"title": "Prescriptions",
|
||||
"description": "Medications prescribed",
|
||||
"total": "Total issued",
|
||||
"active": "Active"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Tasks",
|
||||
"description": "Care-team workload",
|
||||
"open": "Open",
|
||||
"completed": "Completed"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"subtitle": "An audit log of record changes across the clinic.",
|
||||
"changesToday": "Changes today",
|
||||
"thisWeek": "This week",
|
||||
"totalRecorded": "Total recorded",
|
||||
"empty": "No activity yet. Changes to patients, notes, appointments, prescriptions and tasks will appear here."
|
||||
},
|
||||
"settings": {
|
||||
"tabs": {
|
||||
"profile": "Profile",
|
||||
|
||||
Reference in New Issue
Block a user