feat(ui): clinician-focused UX pass

- responsive app shell: persistent sidebar at md+, accessible off-canvas
  drawer (focus trap, Esc, dialog semantics) + hamburger below md
- active-route highlight, localized role labels, greet by name
- visible focus rings, AA-contrast muted text, 44px primary actions
- sign-in: autofocus, show/hide password, always-enabled submit
- Be Vietnam Pro via next/font; prefers-reduced-motion guard; skip-link
This commit is contained in:
2026-07-25 01:51:50 +07:00
parent 164c6125c2
commit 653aca1d1b
16 changed files with 345 additions and 81 deletions
@@ -36,6 +36,7 @@ import { inviteUserAction } from "./actions";
export function InviteUserForm() {
const t = useTranslations("admin.invite");
const tRoles = useTranslations("roles");
const [state, dispatchAction, isPending] = useActionState<InviteUserState, FormData>(
inviteUserAction,
@@ -120,7 +121,7 @@ export function InviteUserForm() {
>
{appRoles.map((r) => (
<option key={r} value={r}>
{r}
{tRoles(r)}
</option>
))}
</select>
@@ -138,12 +139,7 @@ export function InviteUserForm() {
</p>
)}
<Button
type="submit"
className="w-full"
disabled={isPending || (!form.formState.isValid && form.formState.isDirty)}
aria-disabled={isPending}
>
<Button type="submit" size="lg" className="w-full" disabled={isPending}>
{isPending ? t("submitting") : t("submit")}
</Button>
</form>
+6 -3
View File
@@ -13,20 +13,23 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
await params;
const t = await getTranslations("dashboard");
const tRoles = await getTranslations("roles");
const session = await getServerSession();
// session is guaranteed non-null by the parent (app)/layout.tsx gate,
// but we guard here to satisfy TypeScript's strict null checks.
const email = session?.user.email ?? "";
const role = session?.role ?? "";
const role = session?.role;
// Clinicians think in names, not emails — greet by full_name when set.
const name = session?.fullName || email;
return (
<div className="px-8 py-10">
<h1 className="text-foreground text-2xl font-semibold">{t("welcome", { email })}</h1>
<h1 className="text-foreground text-2xl font-semibold">{t("welcome", { name })}</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("roleLabel")}{" "}
<span className="bg-muted text-foreground rounded px-1.5 py-0.5 text-xs font-medium">
{role}
{role ? tRoles(role) : ""}
</span>
</p>
<p className="text-muted-foreground mt-6 text-sm">{t("placeholder")}</p>
+2 -2
View File
@@ -50,10 +50,10 @@ export default async function AppLayout({
return null;
}
const { user, role } = session;
const { user, role, fullName } = session;
return (
<AppShell email={user.email ?? ""} role={role} locale={locale}>
<AppShell email={user.email ?? ""} fullName={fullName} role={role} locale={locale}>
{children}
</AppShell>
);
+32 -16
View File
@@ -14,10 +14,11 @@
* side or server-side.
*/
import { useActionState, useEffect } from "react";
import { useActionState, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -48,6 +49,10 @@ export function SignInForm() {
const { errors: fieldErrors } = form.formState;
// Password reveal toggle — shared clinic PCs + VN keyboards make mistyped
// passwords common; letting the doctor verify what they typed cuts retries.
const [showPassword, setShowPassword] = useState(false);
// Sync server-returned fieldErrors into RHF so the inline error UI is
// consistent regardless of where the error originated.
useEffect(() => {
@@ -83,6 +88,7 @@ export function SignInForm() {
<Input
id="email"
type="email"
autoFocus
autoComplete="email"
autoCapitalize="none"
spellCheck={false}
@@ -101,15 +107,28 @@ export function SignInForm() {
{/* Password field */}
<div className="space-y-1.5">
<Label htmlFor="password">{t("passwordLabel")}</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
disabled={isPending}
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? "password-error" : undefined}
{...form.register("password")}
/>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
disabled={isPending}
className="pr-10"
aria-invalid={!!fieldErrors.password}
aria-describedby={fieldErrors.password ? "password-error" : undefined}
{...form.register("password")}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
disabled={isPending}
aria-label={showPassword ? t("hidePassword") : t("showPassword")}
aria-pressed={showPassword}
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring absolute inset-y-0 right-0 flex w-10 items-center justify-center rounded-md focus:outline-none focus-visible:ring-2"
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
{fieldErrors.password && (
<p id="password-error" className="text-destructive text-sm" role="alert">
{fieldErrors.password.message}
@@ -125,12 +144,9 @@ export function SignInForm() {
)}
{/* Submit */}
<Button
type="submit"
className="w-full"
disabled={isPending || (!form.formState.isValid && form.formState.isDirty)}
aria-disabled={isPending}
>
{/* Submit stays enabled on partial/invalid input: a greyed-out primary
button reads as "app broken". Errors surface inline on submit. */}
<Button type="submit" size="lg" className="w-full" disabled={isPending}>
{isPending ? t("submitting") : t("submit")}
</Button>
</form>
+12 -1
View File
@@ -4,6 +4,7 @@
import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { Be_Vietnam_Pro } from "next/font/google";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { routing } from "@/i18n/routing";
@@ -12,6 +13,16 @@ import { SessionProvider } from "@/lib/auth/session-provider";
import { Toaster } from "@/components/ui/sonner";
import "../globals.css";
// Vietnamese-complete web font. `vietnamese` subset covers stacked diacritics
// (ế, ữ, ộ …) that render inconsistently across the low-end machines clinics
// actually own. Exposed as a CSS variable consumed by --font-sans in globals.css.
const beVietnamPro = Be_Vietnam_Pro({
subsets: ["latin", "vietnamese"],
weight: ["400", "500", "600", "700"],
variable: "--font-be-vietnam-pro",
display: "swap",
});
export const metadata: Metadata = {
title: "BSK Clinic",
description: "Educational clinic management rewrite",
@@ -58,7 +69,7 @@ export default async function LocaleLayout({
// explicitly protected path prefixes (/dashboard, /admin).
return (
<html lang={locale} suppressHydrationWarning>
<html lang={locale} className={beVietnamPro.variable} suppressHydrationWarning>
<body>
<NextIntlClientProvider>
{/* Toaster is mounted once globally here so toast() calls from any
+15 -2
View File
@@ -6,9 +6,10 @@
--color-primary: oklch(0.205 0 0);
--color-primary-foreground: oklch(0.985 0 0);
--color-muted: oklch(0.97 0 0);
--color-muted-foreground: oklch(0.556 0 0);
/* Darkened from 0.556 → 0.50 for WCAG AA at small (text-xs) sizes. */
--color-muted-foreground: oklch(0.5 0 0);
--color-border: oklch(0.922 0 0);
--font-sans: ui-sans-serif, system-ui, sans-serif;
--font-sans: var(--font-be-vietnam-pro), ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, "Courier New", monospace;
--radius: 0.5rem;
}
@@ -35,3 +36,15 @@ body {
color: var(--color-foreground);
font-family: var(--font-sans);
}
/* Respect users who ask the OS to reduce motion (vestibular safety). */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
/**
* AppShellFrame — Client Component.
*
* Owns the responsive chrome: a persistent sidebar at `md+`, and an off-canvas
* drawer + hamburger top-bar below `md`. The (server-rendered) sidebar tree is
* passed in as the `sidebar` prop so this file stays free of data fetching.
*
* The mobile drawer is a proper modal: focus moves in on open, Tab is trapped,
* Esc closes it, body scroll is locked, and focus returns to the trigger on
* close. Uses `h-dvh` (not `h-screen`) so mobile browser chrome doesn't clip
* the layout. Drawer auto-closes on route change.
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Menu } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname } from "@/i18n/navigation";
const DRAWER_ID = "app-mobile-drawer";
const MAIN_ID = "main-content";
type AppShellFrameProps = {
sidebar: ReactNode;
children: ReactNode;
};
export function AppShellFrame({ sidebar, children }: AppShellFrameProps) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const t = useTranslations("app");
const drawerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
// Close the drawer on route change (nav tap, redirect). Adjusting state
// during render on a changed value is the React-recommended pattern — no
// effect, no cascading render.
const [lastPath, setLastPath] = useState(pathname);
if (pathname !== lastPath) {
setLastPath(pathname);
if (open) setOpen(false);
}
// Modal behaviors while the drawer is open: move focus in, trap Tab, Esc to
// close, lock body scroll, restore focus to the trigger on close. These are
// external-system syncs (DOM focus / listeners / body style), the legitimate
// use of an effect.
useEffect(() => {
if (!open) return;
const drawer = drawerRef.current;
if (!drawer) return;
const focusable = () =>
Array.from(
drawer.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),select,input,textarea,[tabindex]:not([tabindex="-1"])',
),
);
focusable()[0]?.focus();
const trigger = triggerRef.current;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
return;
}
if (e.key !== "Tab") return;
const items = focusable();
const first = items[0];
const last = items[items.length - 1];
if (!first || !last) return;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
// focus the trigger captured at open-time (ref may have changed by now).
trigger?.focus();
};
}, [open]);
return (
<div className="flex h-dvh overflow-hidden">
{/* Skip-link: first focusable, jumps keyboard users past the nav. */}
<a
href={`#${MAIN_ID}`}
className="bg-background text-foreground focus-visible:ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:shadow focus-visible:ring-2"
>
{t("skipToContent")}
</a>
{/* Persistent sidebar — desktop only. */}
<div className="hidden md:flex">{sidebar}</div>
{/* Off-canvas drawer — below md, only when opened. */}
{open && (
<div className="fixed inset-0 z-40 md:hidden">
<div
className="absolute inset-0 bg-black/40"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
<div
ref={drawerRef}
id={DRAWER_ID}
role="dialog"
aria-modal="true"
aria-label={t("menu")}
className="absolute inset-y-0 left-0 flex"
>
{sidebar}
</div>
</div>
)}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Mobile top bar with hamburger — below md only. */}
<header className="border-border flex h-14 shrink-0 items-center gap-2 border-b px-3 md:hidden">
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
aria-label={t("openMenu")}
aria-expanded={open}
aria-controls={DRAWER_ID}
className="text-foreground hover:bg-muted focus-visible:ring-ring flex size-11 items-center justify-center rounded-md focus:outline-none focus-visible:ring-2"
>
<Menu className="size-5" />
</button>
<span className="text-foreground text-lg font-bold tracking-tight">BSK</span>
</header>
<main id={MAIN_ID} tabIndex={-1} className="flex-1 overflow-y-auto outline-none">
{children}
</main>
</div>
</div>
);
}
+11 -11
View File
@@ -1,30 +1,30 @@
/**
* AppShell — Server Component.
*
* Composes the full authenticated layout: fixed sidebar on the left,
* scrollable main content area on the right. Receives user info from the
* (app) layout which has already validated session + role.
*
* No client state here — sidebar is server-rendered, top-bar lives inside
* the sidebar's bottom section for Phase 1 simplicity.
* Renders the server-side Sidebar and hands it to AppShellFrame, which owns the
* responsive chrome (persistent at md+, off-canvas drawer below md). Receives
* user info from the (app) layout which has already validated session + role.
*/
import type { ReactNode } from "react";
import type { AppRole } from "@/lib/db/roles";
import { Sidebar } from "@/components/app-shell/sidebar";
import { AppShellFrame } from "@/components/app-shell/app-shell-frame";
type AppShellProps = {
email: string;
fullName: string | null;
role: AppRole;
locale: string;
children: ReactNode;
};
export function AppShell({ email, role, locale, children }: AppShellProps) {
export function AppShell({ email, fullName, role, locale, children }: AppShellProps) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar email={email} role={role} locale={locale} />
<main className="flex-1 overflow-y-auto">{children}</main>
</div>
<AppShellFrame
sidebar={<Sidebar email={email} fullName={fullName} role={role} locale={locale} />}
>
{children}
</AppShellFrame>
);
}
+7 -3
View File
@@ -8,6 +8,7 @@
* locale prefix rewriting transparently.
*/
import { useId } from "react";
import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing } from "@/i18n/routing";
@@ -17,6 +18,9 @@ export function LocaleSwitcher() {
const router = useRouter();
const pathname = usePathname();
const t = useTranslations("app.localeSwitcher");
// useId keeps the id unique even when the sidebar is rendered in two DOM
// slots (desktop rail + mobile drawer) — no duplicate id / label collision.
const selectId = useId();
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const nextLocale = e.target.value as (typeof routing.locales)[number];
@@ -25,15 +29,15 @@ export function LocaleSwitcher() {
return (
<div className="flex items-center gap-1.5">
<label htmlFor="locale-select" className="text-muted-foreground sr-only text-xs">
<label htmlFor={selectId} className="text-muted-foreground sr-only text-xs">
{t("label")}
</label>
<select
id="locale-select"
id={selectId}
value={locale}
onChange={handleChange}
aria-label={t("label")}
className="border-border bg-background text-foreground rounded-md border px-2 py-1 text-xs focus:outline-none"
className="border-border bg-background text-foreground focus-visible:ring-ring rounded-md border px-2 py-2 text-xs focus:outline-none focus-visible:ring-2"
>
{routing.locales.map((l) => (
<option key={l} value={l}>
+58
View File
@@ -0,0 +1,58 @@
"use client";
/**
* Sidebar navigation list — Client Component.
*
* Split out from the (Server) Sidebar so it can read the current pathname and
* mark the active route. It imports ROLE_MENU directly (icons are client-safe
* lucide components) rather than receiving menu items as props — component
* types can't cross the RSC boundary.
*
* Active detection: next-intl's usePathname() returns the locale-stripped
* pathname (e.g. "/dashboard"), matching the locale-relative item.href.
*/
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { ROLE_MENU } from "@/lib/auth/role-menu";
import type { AppRole } from "@/lib/db/roles";
type SidebarNavProps = {
role: AppRole;
locale: string;
};
export function SidebarNav({ role, locale }: SidebarNavProps) {
const t = useTranslations();
const pathname = usePathname();
const items = ROLE_MENU[role];
return (
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Main navigation">
<ul className="space-y-0.5">
{items.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<li key={item.href}>
<Link
href={item.href}
locale={locale as "vi" | "en"}
aria-current={isActive ? "page" : undefined}
className={`flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors ${
isActive
? "bg-muted text-foreground font-medium"
: "text-foreground hover:bg-muted"
}`}
>
<Icon className="size-4 shrink-0" />
{t(item.labelKey as Parameters<typeof t>[0])}
</Link>
</li>
);
})}
</ul>
</nav>
);
}
+11 -29
View File
@@ -7,21 +7,20 @@
*/
import { getTranslations } from "next-intl/server";
import { Link } from "@/i18n/navigation";
import { ROLE_MENU } from "@/lib/auth/role-menu";
import type { AppRole } from "@/lib/db/roles";
import { SidebarNav } from "@/components/app-shell/sidebar-nav";
import { SignOutButton } from "@/components/app-shell/sign-out-button";
import { LocaleSwitcher } from "@/components/app-shell/locale-switcher";
type SidebarProps = {
email: string;
fullName: string | null;
role: AppRole;
locale: string;
};
export async function Sidebar({ email, role, locale }: SidebarProps) {
const t = await getTranslations();
const items = ROLE_MENU[role];
export async function Sidebar({ email, fullName, role, locale }: SidebarProps) {
const tRoles = await getTranslations("roles");
return (
<aside className="bg-background border-border flex h-full w-56 shrink-0 flex-col border-r">
@@ -31,34 +30,17 @@ export async function Sidebar({ email, role, locale }: SidebarProps) {
<p className="text-muted-foreground text-xs">Clinic Management</p>
</div>
{/* User info */}
{/* User info — greet by name when set; email drops to a secondary line. */}
<div className="border-border border-b px-4 py-3">
<p className="text-foreground truncate text-sm font-medium">{email}</p>
<span className="bg-muted text-muted-foreground mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-medium">
{role}
<p className="text-foreground truncate text-sm font-medium">{fullName || email}</p>
{fullName && <p className="text-muted-foreground truncate text-xs">{email}</p>}
<span className="bg-muted text-foreground mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-medium">
{tRoles(role)}
</span>
</div>
{/* Nav items */}
<nav className="flex-1 overflow-y-auto px-2 py-3" aria-label="Main navigation">
<ul className="space-y-0.5">
{items.map((item) => {
const Icon = item.icon;
return (
<li key={item.href}>
<Link
href={item.href}
locale={locale as "vi" | "en"}
className="text-foreground hover:bg-muted flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors"
>
<Icon className="size-4 shrink-0" />
{t(item.labelKey as Parameters<typeof t>[0])}
</Link>
</li>
);
})}
</ul>
</nav>
{/* Nav items (client component: marks the active route) */}
<SidebarNav role={role} locale={locale} />
{/* Bottom actions */}
<div className="border-border space-y-1 border-t px-2 py-3">
-1
View File
@@ -22,7 +22,6 @@ function SignOutButtonInner() {
<Button
type="submit"
variant="ghost"
size="sm"
disabled={pending}
className="w-full justify-start gap-2"
>
+1 -1
View File
@@ -22,7 +22,7 @@ const buttonVariants = cva(
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
lg: "h-11 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type}
data-slot="input"
className={cn(
"border-input selection:bg-primary selection:text-primary-foreground file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input selection:bg-primary selection:text-primary-foreground file:text-foreground placeholder:text-muted-foreground dark:bg-input/30 h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className,
+17 -2
View File
@@ -18,18 +18,30 @@
"success": "Invited {email}. If the email does not arrive within 5 minutes, copy the invite link from the Supabase dashboard under Authentication → Users.",
"errorForbidden": "You do not have permission to invite users.",
"errorEmailTaken": "This user is already enrolled in BSK.",
"errorGeneric": "Something went wrong. Please try again."
"errorGeneric": "Something went wrong. Please try again.",
"tooManyRequests": "Too many invites sent. Please try again in a few minutes."
}
},
"roles": {
"admin": "Admin",
"doctor": "Doctor",
"nurse": "Nurse",
"receptionist": "Receptionist",
"cashier": "Cashier",
"patient": "Patient"
},
"auth": {
"signIn": {
"title": "Sign in to BSK",
"subtitle": "Enter your credentials to access the clinic system.",
"emailLabel": "Email",
"passwordLabel": "Password",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submit": "Sign in",
"submitting": "Signing in…",
"invalidCredentials": "Invalid email or password.",
"tooManyAttempts": "Too many attempts. Please try again in a few minutes.",
"genericError": "Something went wrong. Please try again."
}
},
@@ -50,6 +62,9 @@
},
"app": {
"signOut": "Sign out",
"openMenu": "Open navigation menu",
"menu": "Navigation menu",
"skipToContent": "Skip to content",
"unenrolledError": "Your account is not enrolled. Please contact an administrator.",
"localeSwitcher": {
"label": "Language",
@@ -60,7 +75,7 @@
}
},
"dashboard": {
"welcome": "Welcome, {email}",
"welcome": "Welcome, {name}",
"roleLabel": "Your role:",
"placeholder": "Dashboard content is coming in a future phase."
}
+17 -2
View File
@@ -18,18 +18,30 @@
"success": "Đã mời {email}. Nếu email không đến trong vòng 5 phút, hãy sao chép liên kết mời từ bảng điều khiển Supabase tại Authentication → Users.",
"errorForbidden": "Bạn không có quyền mời người dùng.",
"errorEmailTaken": "Người dùng này đã được đăng ký trong BSK.",
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại."
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại.",
"tooManyRequests": "Bạn đã gửi quá nhiều lời mời. Vui lòng thử lại sau ít phút."
}
},
"roles": {
"admin": "Quản trị",
"doctor": "Bác sĩ",
"nurse": "Điều dưỡng",
"receptionist": "Lễ tân",
"cashier": "Thu ngân",
"patient": "Bệnh nhân"
},
"auth": {
"signIn": {
"title": "Đăng nhập BSK",
"subtitle": "Nhập thông tin đăng nhập để truy cập hệ thống phòng khám.",
"emailLabel": "Email",
"passwordLabel": "Mật khẩu",
"showPassword": "Hiện mật khẩu",
"hidePassword": "Ẩn mật khẩu",
"submit": "Đăng nhập",
"submitting": "Đang đăng nhập…",
"invalidCredentials": "Email hoặc mật khẩu không đúng.",
"tooManyAttempts": "Quá nhiều lần thử. Vui lòng thử lại sau ít phút.",
"genericError": "Đã xảy ra lỗi. Vui lòng thử lại."
}
},
@@ -50,6 +62,9 @@
},
"app": {
"signOut": "Đăng xuất",
"openMenu": "Mở menu điều hướng",
"menu": "Menu điều hướng",
"skipToContent": "Bỏ qua đến nội dung",
"unenrolledError": "Tài khoản của bạn chưa được đăng ký. Vui lòng liên hệ quản trị viên.",
"localeSwitcher": {
"label": "Ngôn ngữ",
@@ -60,7 +75,7 @@
}
},
"dashboard": {
"welcome": "Xin chào, {email}",
"welcome": "Xin chào, {name}",
"roleLabel": "Vai trò của bạn:",
"placeholder": "Nội dung trang chủ sẽ được bổ sung trong giai đoạn tiếp theo."
}