From ca8739dc014fe1f94552d8481ba8a4a3bf43dbe7 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 25 Jul 2026 15:27:44 +0700 Subject: [PATCH] =?UTF-8?q?feat(ux):=20doctor=20ergonomics=20=E2=80=94=20c?= =?UTF-8?q?all-next,=20live-queue=20indicator,=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app/[locale]/(app)/checkups/[id]/page.tsx | 19 ++++- .../prescription/prescription-composer.tsx | 10 +++ app/[locale]/(app)/checkups/checkup-form.tsx | 82 +++++++++++++++++-- app/[locale]/(app)/queue/actions.ts | 33 ++++++++ app/[locale]/(app)/queue/call-next-button.tsx | 65 +++++++++++++++ app/[locale]/(app)/queue/page.tsx | 28 +++++++ app/[locale]/(app)/queue/queue-realtime.tsx | 51 ++++++++++-- lib/billing/dose-presets.ts | 20 +++++ messages/en.json | 13 ++- messages/vi.json | 13 ++- .../20260725160000_bsk_call_next.sql | 51 ++++++++++++ types/supabase-bsk.ts | 4 + 12 files changed, 369 insertions(+), 20 deletions(-) create mode 100644 app/[locale]/(app)/queue/call-next-button.tsx create mode 100644 lib/billing/dose-presets.ts create mode 100644 supabase/migrations/20260725160000_bsk_call_next.sql diff --git a/app/[locale]/(app)/checkups/[id]/page.tsx b/app/[locale]/(app)/checkups/[id]/page.tsx index 37ae17a..d6b2eda 100644 --- a/app/[locale]/(app)/checkups/[id]/page.tsx +++ b/app/[locale]/(app)/checkups/[id]/page.tsx @@ -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} />

{t("title")}

diff --git a/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx b/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx index b396122..5477e03 100644 --- a/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx +++ b/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx @@ -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 (
+ + {dosePresets.map((d) => ( + +
@@ -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)} />
diff --git a/app/[locale]/(app)/checkups/checkup-form.tsx b/app/[locale]/(app)/checkups/checkup-form.tsx index d12e83c..7c6d365 100644 --- a/app/[locale]/(app)/checkups/checkup-form.tsx +++ b/app/[locale]/(app)/checkups/checkup-form.tsx @@ -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(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(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