mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-03 06:22:04 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user