feat(ux): doctor ergonomics — call-next, live-queue indicator, guards

Implements the deferred NEXT items from the clinician UX review, which
targeted the doctor's core loop:

- call_next_patient RPC (advisory-locked so two staff can't double-call) with
  a prominent per-shift button and an Alt+N shortcut that lands straight on
  the checkup screen
- realtime queue indicator: live/disconnected as colour + icon + text plus a
  last-updated time, so a frozen queue is never trusted silently
- unsaved-changes guard on the checkup form (beforeunload + in-app marker)
- diagnosis quick-pick sourced from recent diagnoses, and Vietnamese dose
  presets on every prescription row, so the doctor types almost nothing

typecheck/lint/build green; 97 unit tests still passing.
This commit is contained in:
2026-07-25 15:27:44 +07:00
parent 345238b20b
commit ca8739dc01
12 changed files with 369 additions and 20 deletions
+18 -1
View File
@@ -35,15 +35,31 @@ export default async function CheckupPage({
if (!c) notFound();
const [{ data: customer }, { data: templates }] = await Promise.all([
const [{ data: customer }, { data: templates }, { data: recentDiagnoses }] = await Promise.all([
supabase.from("customers").select("last_name, first_name, dob, gender, phone").eq("id", c.customer_id).maybeSingle(),
supabase
.from("checkup_templates")
.select("id, name, gender, fields")
.eq("deleted", false)
.order("name", { ascending: true }),
supabase
.from("checkups")
.select("diagnosis")
.not("diagnosis", "is", null)
.eq("deleted", false)
.order("checkup_date", { ascending: false })
.limit(200),
]);
// De-dupe + trim recent diagnoses for the quick-pick, capped at 50 entries.
const diagnosisSuggestions = [
...new Set(
(recentDiagnoses ?? [])
.map((r) => r.diagnosis?.trim())
.filter((d): d is string => !!d),
),
].slice(0, 50);
const patientName = customer ? `${customer.last_name} ${customer.first_name}` : "—";
// Prefer templates matching the patient's gender (or applicable to "any");
@@ -113,6 +129,7 @@ export default async function CheckupPage({
templates={templateOptions}
initialTemplateId={c.template_id}
initialTemplateValues={templateValues}
diagnosisSuggestions={diagnosisSuggestions}
/>
<p className="sr-only">{t("title")}</p>
</div>
@@ -17,6 +17,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { dosePresets } from "@/lib/billing/dose-presets";
import {
paymentMethods,
type MarkPaidState,
@@ -24,6 +25,8 @@ import {
} from "@/lib/billing/prescription-schema";
import { markPaidAction, savePrescriptionAction } from "./actions";
const DOSE_PRESETS_LIST_ID = "dose-presets";
const vnd = (n: number) => `${new Intl.NumberFormat("vi-VN").format(n)}`;
const SELECT =
@@ -120,6 +123,12 @@ export function PrescriptionComposer({
return (
<div className="space-y-8">
<datalist id={DOSE_PRESETS_LIST_ID}>
{dosePresets.map((d) => (
<option key={d} value={d} />
))}
</datalist>
<form action={saveDispatch} noValidate className="space-y-6">
<input type="hidden" name="checkupId" value={checkupId} readOnly />
<input type="hidden" name="medicineLines" value={medicineLinesJson} readOnly />
@@ -166,6 +175,7 @@ export function PrescriptionComposer({
id={`dosage-${row.key}`}
value={row.dosage}
disabled={isSaving}
list={DOSE_PRESETS_LIST_ID}
onChange={(e) => updateMedicineRow(row.key, "dosage", e.target.value)}
/>
</div>
+75 -7
View File
@@ -6,7 +6,7 @@
* (redirects to the queue on success).
*/
import { useActionState, useState } from "react";
import { useActionState, useEffect, useRef, useState, type RefObject } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
@@ -41,11 +41,13 @@ export function CheckupForm({
templates,
initialTemplateId,
initialTemplateValues,
diagnosisSuggestions = [],
}: {
defaults: CheckupDefaults;
templates: CheckupTemplateOption[];
initialTemplateId: number | null;
initialTemplateValues: TemplateValue[];
diagnosisSuggestions?: string[];
}) {
const t = useTranslations("checkups");
const [state, dispatch, isPending] = useActionState<CheckupSaveState, FormData>(saveCheckupAction, {
@@ -53,6 +55,37 @@ export function CheckupForm({
});
const formError = state.status === "error" ? state.formError : null;
// Unsaved-changes guard: any edit flips `dirty`; saving clears it so a
// successful (or in-flight) submit never triggers the beforeunload prompt.
// beforeunload doesn't fire for client-side route changes, so we also show
// an in-app marker near the Save button.
const [dirty, setDirty] = useState(false);
const diagnosisRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (!dirty || isPending) return;
function onBeforeUnload(e: BeforeUnloadEvent) {
e.preventDefault();
e.returnValue = "";
}
window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, [dirty, isPending]);
// Diagnosis quick-pick: a <textarea> cannot use a native <datalist>, so
// recent diagnoses are offered via a small select above the field that
// appends the chosen text into the (uncontrolled) textarea via ref — the
// least disruptive option since it doesn't require converting the field to
// controlled state.
function applyDiagnosisSuggestion(text: string) {
if (!text) return;
const el = diagnosisRef.current;
if (!el) return;
const current = el.value.trim();
el.value = current ? `${current}; ${text}` : text;
setDirty(true);
}
// Template picker — plain state, no effects. Selecting a template swaps the
// rendered field labels below; typed values are kept per-label so switching
// back and forth doesn't lose what was already entered.
@@ -76,12 +109,17 @@ export function CheckupForm({
</div>
);
const area = (name: keyof CheckupDefaults, label: string) => (
const area = (
name: keyof CheckupDefaults,
label: string,
ref?: RefObject<HTMLTextAreaElement | null>,
) => (
<div className="space-y-1.5">
<Label htmlFor={name}>{label}</Label>
<textarea
id={name}
name={name}
ref={ref}
rows={3}
defaultValue={defaults[name]}
disabled={isPending}
@@ -91,7 +129,13 @@ export function CheckupForm({
);
return (
<form action={dispatch} noValidate className="space-y-5">
<form
action={dispatch}
noValidate
className="space-y-5"
onChange={() => setDirty(true)}
onSubmit={() => setDirty(false)}
>
<input type="hidden" name="id" value={defaults.id} />
<fieldset className="border-border grid gap-4 rounded-md border p-4 sm:grid-cols-3">
@@ -150,7 +194,26 @@ export function CheckupForm({
</fieldset>
)}
{area("diagnosis", t("diagnosis"))}
{diagnosisSuggestions.length > 0 && (
<div className="space-y-1.5">
<Label htmlFor="diagnosisQuickPick">{t("quickDiagnosis")}</Label>
<select
id="diagnosisQuickPick"
value=""
disabled={isPending}
onChange={(e) => applyDiagnosisSuggestion(e.target.value)}
className={`${CONTROL} h-10`}
>
<option value=""></option>
{diagnosisSuggestions.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
)}
{area("diagnosis", t("diagnosis"), diagnosisRef)}
{area("conclusion", t("conclusion"))}
{area("notes", t("notes"))}
@@ -177,9 +240,14 @@ export function CheckupForm({
</p>
)}
<Button type="submit" size="lg" disabled={isPending}>
{isPending ? t("saving") : t("save")}
</Button>
<div className="flex items-center gap-3">
<Button type="submit" size="lg" disabled={isPending}>
{isPending ? t("saving") : t("save")}
</Button>
{dirty && !isPending && (
<span className="text-muted-foreground text-xs">{t("unsavedChanges")}</span>
)}
</div>
</form>
);
}
+33
View File
@@ -10,6 +10,7 @@
import { getLocale, getTranslations } from "next-intl/server";
import { revalidatePath } from "next/cache";
import { redirect } from "@/i18n/navigation";
import { getServerSession } from "@/lib/auth/get-server-session";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import type { AppRole } from "@/lib/db/roles";
@@ -128,6 +129,38 @@ export async function setQueueCounterAction(
return { status: "success" };
}
/**
* One-key "call next patient": picks the lowest-queue_number waiting checkup
* for the given shift (call_next_patient RPC, advisory-locked server-side)
* and marks it in_progress. On success, redirects straight to the checkup
* screen so the doctor never has to find the row in the list themselves.
*/
export async function callNextPatientAction(formData: FormData): Promise<void> {
const session = await getServerSession();
if (!isClinical(session?.role)) return;
const shiftId = Number(formData.get("shiftId"));
if (!Number.isFinite(shiftId)) return;
const supabase = await createSupabaseServerClient();
const { data: nextId, error } = await supabase.rpc("call_next_patient", { p_shift_id: shiftId });
if (error) return;
await supabase.rpc("log_audit", {
p_action: "checkup.call_next",
p_entity: "checkups",
...(nextId != null ? { p_entity_id: String(nextId) } : {}),
p_details: { shiftId, found: nextId != null },
});
const locale = await getLocale();
revalidatePath(`/${locale}/queue`);
if (nextId != null) {
return redirect({ href: `/${locale}/checkups/${nextId}`, locale });
}
}
export async function callPatientAction(formData: FormData): Promise<void> {
const session = await getServerSession();
if (!isClinical(session?.role)) return;
@@ -0,0 +1,65 @@
"use client";
/**
* One-key "call next patient" button for a single shift. Submits to
* callNextPatientAction (which redirects straight to the checkup screen on
* success). When `enableShortcut` is set, also listens for the global
* Alt+N shortcut and submits the same form — guarded so it never fires
* while the user is typing in a field.
*/
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { callNextPatientAction } from "./actions";
const TYPING_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
export function CallNextButton({
shiftId,
shiftLabel,
waitingCount,
enableShortcut,
}: {
shiftId: number;
shiftLabel: string;
waitingCount: number;
enableShortcut: boolean;
}) {
const t = useTranslations("queue");
const formRef = useRef<HTMLFormElement>(null);
useEffect(() => {
if (!enableShortcut) return;
function onKeyDown(event: KeyboardEvent) {
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.key.toLowerCase() !== "n") return;
const target = event.target as HTMLElement | null;
if (target && (TYPING_TAGS.has(target.tagName) || target.isContentEditable)) return;
event.preventDefault();
formRef.current?.requestSubmit();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [enableShortcut]);
return (
<div className="flex flex-col gap-1">
<form ref={formRef} action={callNextPatientAction} className="flex items-center gap-3">
<input type="hidden" name="shiftId" value={shiftId} />
<Button type="submit" size="lg">
{t("callNext")}
</Button>
{enableShortcut && <span className="text-muted-foreground text-xs">{t("callNextHint")}</span>}
</form>
<span className="text-muted-foreground text-xs">
{t("callNextFor", { shift: shiftLabel, count: waitingCount })}
</span>
</div>
);
}
+28
View File
@@ -13,6 +13,7 @@ import { Button } from "@/components/ui/button";
import { RegisterForm } from "./register-form";
import { QueueRealtime } from "./queue-realtime";
import { CounterForm } from "./counter-form";
import { CallNextButton } from "./call-next-button";
import { callPatientAction } from "./actions";
const COUNTER_MANAGERS = new Set(["admin", "receptionist"]);
@@ -59,11 +60,38 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
const shiftCode = new Map((shifts ?? []).map((s) => [s.id, s.code]));
const counterByShift = new Map((counters ?? []).map((c) => [c.shift_id, c.last_number]));
const waitingCountByShift = new Map<number, number>();
for (const r of rows) {
if (r.status === "waiting" && r.shift_id != null) {
waitingCountByShift.set(r.shift_id, (waitingCountByShift.get(r.shift_id) ?? 0) + 1);
}
}
const shiftsWithWaiting = (shifts ?? []).filter((s) => (waitingCountByShift.get(s.id) ?? 0) > 0);
return (
<div className="mx-auto max-w-3xl px-4 py-10">
<QueueRealtime />
<h1 className="text-foreground mb-6 text-xl font-semibold">{t("title")}</h1>
<section className="border-border mb-8 rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-medium">{t("callNext")}</h2>
{shiftsWithWaiting.length === 0 ? (
<p className="text-muted-foreground text-sm">{t("noneWaiting")}</p>
) : (
<div className="flex flex-wrap gap-6">
{shiftsWithWaiting.map((s, i) => (
<CallNextButton
key={s.id}
shiftId={s.id}
shiftLabel={tShift(s.code)}
waitingCount={waitingCountByShift.get(s.id) ?? 0}
enableShortcut={i === 0}
/>
))}
</div>
)}
</section>
<section className="border-border mb-8 rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-medium">{t("counters")}</h2>
<ul className="grid gap-3 sm:grid-cols-3">
+43 -8
View File
@@ -6,30 +6,65 @@
* Realtime authorization runs through RLS, so only BSK-enrolled users receive
* these events. If Realtime isn't enabled on the table, the page still works —
* it just won't update until the next navigation.
*
* Also renders a small connection/staleness indicator (color + icon + text,
* never color alone) so a doctor never trusts a frozen queue: live vs
* disconnected, plus the wall-clock time of the last received change. State is
* set from the subscribe/event callbacks (not the effect body) to satisfy the
* react-hooks/purity rule.
*/
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Wifi, WifiOff } from "lucide-react";
import { createSupabaseBrowserClient } from "@/lib/supabase/client";
type ConnectionStatus = "live" | "disconnected";
const timeFormatter = new Intl.DateTimeFormat("vi-VN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
export function QueueRealtime() {
const router = useRouter();
const t = useTranslations("queue");
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
useEffect(() => {
const supabase = createSupabaseBrowserClient();
const channel = supabase
.channel("bsk:queue")
.on(
"postgres_changes",
{ event: "*", schema: "bsk", table: "checkups" },
() => router.refresh(),
)
.subscribe();
.on("postgres_changes", { event: "*", schema: "bsk", table: "checkups" }, () => {
setLastUpdatedAt(timeFormatter.format(new Date()));
router.refresh();
})
.subscribe((subscribeStatus) => {
setStatus(subscribeStatus === "SUBSCRIBED" ? "live" : "disconnected");
});
return () => {
void supabase.removeChannel(channel);
};
}, [router]);
return null;
const isLive = status === "live";
return (
<div className="text-muted-foreground mb-4 flex items-center gap-1.5 text-xs" role="status">
{isLive ? (
<Wifi className="size-3.5 text-green-600 dark:text-green-400" aria-hidden="true" />
) : (
<WifiOff className="text-destructive size-3.5" aria-hidden="true" />
)}
<span className={isLive ? "text-green-700 dark:text-green-400" : "text-destructive"}>
{isLive ? t("live") : t("disconnected")}
</span>
{lastUpdatedAt && <span>· {t("updatedAt", { time: lastUpdatedAt })}</span>}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Common Vietnamese dosage shorthand for the shared <datalist> on each
* prescription line's dosage input, so a doctor types almost nothing for a
* routine dose. These are literal clinical shorthand — data, not UI copy —
* so they are NOT translated via next-intl/messages.
*/
export const dosePresets: readonly string[] = [
"1 viên x 2 lần/ngày",
"1 viên x 3 lần/ngày",
"2 viên x 2 lần/ngày",
"2 viên x 3 lần/ngày",
"1/2 viên x 2 lần/ngày",
"1 gói x 2 lần/ngày",
"1 ống x 1 lần/ngày",
"sáng 1 - chiều 1",
"sáng 1 - trưa 1 - chiều 1",
"khi cần",
"sau ăn",
"trước ăn",
];
+11 -2
View File
@@ -146,7 +146,14 @@
"counter": "Current number",
"setCounter": "Set",
"saving": "Saving…",
"saved": "Saved."
"saved": "Saved.",
"callNext": "Call next patient",
"callNextHint": "Shortcut: Alt+N",
"callNextFor": "{shift} · {count} waiting",
"noneWaiting": "No patients waiting.",
"live": "Live updates",
"disconnected": "Disconnected — please reload the page",
"updatedAt": "Updated at {time}"
},
"checkups": {
"title": "Checkup",
@@ -171,7 +178,9 @@
"errorGeneric": "Something went wrong. Please try again.",
"template": "Checkup template",
"templateNone": "— No template —",
"templateFields": "Template fields"
"templateFields": "Template fields",
"quickDiagnosis": "Common diagnoses",
"unsavedChanges": "Unsaved changes"
},
"billing": {
"title": "Prescription & billing",
+11 -2
View File
@@ -146,7 +146,14 @@
"counter": "Số thứ tự hiện tại",
"setCounter": "Đặt lại",
"saving": "Đang lưu…",
"saved": "Đã lưu."
"saved": "Đã lưu.",
"callNext": "Gọi bệnh nhân tiếp theo",
"callNextHint": "Phím tắt: Alt+N",
"callNextFor": "{shift} · {count} đang chờ",
"noneWaiting": "Không có bệnh nhân đang chờ.",
"live": "Đang cập nhật trực tiếp",
"disconnected": "Mất kết nối trực tiếp — hãy tải lại trang",
"updatedAt": "Cập nhật lúc {time}"
},
"checkups": {
"title": "Phiếu khám",
@@ -171,7 +178,9 @@
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại.",
"template": "Mẫu khám",
"templateNone": "— Không dùng mẫu —",
"templateFields": "Thông tin theo mẫu"
"templateFields": "Thông tin theo mẫu",
"quickDiagnosis": "Chẩn đoán thường gặp",
"unsavedChanges": "Có thay đổi chưa lưu"
},
"billing": {
"title": "Đơn thuốc & thanh toán",
@@ -0,0 +1,51 @@
-- BSK — one-key "call next patient" for the queue screen.
--
-- bsk.call_next_patient(p_shift_id): picks the lowest queue_number waiting
-- checkup for VN-local "today" + the given shift, marks it in_progress, and
-- returns its id (NULL when the queue is empty). Advisory-locked so two
-- staff clicking "next patient" at the same moment can never grab the same
-- checkup.
CREATE OR REPLACE FUNCTION bsk.call_next_patient(p_shift_id smallint)
RETURNS bigint
LANGUAGE plpgsql
VOLATILE
SECURITY DEFINER
SET search_path = bsk, pg_catalog
AS $$
DECLARE
v_role bsk.app_role := bsk.current_role();
v_id bigint;
BEGIN
IF v_role IS NULL OR v_role NOT IN ('admin', 'receptionist', 'doctor', 'nurse') THEN
RAISE EXCEPTION 'not authorized to call the next patient';
END IF;
PERFORM pg_advisory_xact_lock(hashtext('bsk:call_next')::bigint);
SELECT id INTO v_id
FROM bsk.checkups
WHERE checkup_date = (now() AT TIME ZONE 'Asia/Ho_Chi_Minh')::date
AND shift_id = p_shift_id
AND status = 'waiting'
AND NOT deleted
ORDER BY queue_number ASC
LIMIT 1;
IF v_id IS NULL THEN
RETURN NULL;
END IF;
UPDATE bsk.checkups SET status = 'in_progress' WHERE id = v_id;
RETURN v_id;
END
$$;
COMMENT ON FUNCTION bsk.call_next_patient(smallint) IS
'Picks the lowest-queue_number waiting checkup for VN-local today + the '
'given shift, marks it in_progress, and returns its id (NULL if none '
'waiting). SECURITY DEFINER, role-gated to clinical staff, advisory-locked '
'against two staff calling the same patient concurrently.';
GRANT EXECUTE ON FUNCTION bsk.call_next_patient(smallint) TO authenticated;
+4
View File
@@ -525,6 +525,10 @@ export type Database = {
Args: { p_shift_id: number; p_value: number };
Returns: undefined;
};
call_next_patient: {
Args: { p_shift_id: number };
Returns: number | null;
};
set_staff_role: {
Args: { p_user_id: string; p_role: Database["bsk"]["Enums"]["app_role"] };
Returns: undefined;