mirror of
https://github.com/tiennm99/bsk.git
synced 2026-09-04 02:17:36 +00:00
feat(queue): manual counter control + apply checkup templates
Closes the last two original-app feature gaps: - set_queue_counter RPC (admin/receptionist, advisory-locked, VN-local day) plus per-shift counter display for all staff and a set/reset form for admin/receptionist (orig SetCounterRequest/GetCounterRequest) - checkup templates are now actually applied: pick a template on the checkup screen (filtered to the patient's gender), its fields render as inputs and persist to the new checkups.template_values jsonb; the ultrasound report PDF already reads the template title daily_queue_counters gains a SELECT policy (reads only) — writes stay inside the DEFINER RPCs.
This commit is contained in:
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { fieldsJsonToLabels } from "@/lib/templates/template-schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckupForm } from "../checkup-form";
|
||||
import { DeleteCheckupButton } from "../delete-checkup-button";
|
||||
@@ -26,7 +27,7 @@ export default async function CheckupPage({
|
||||
const { data: c } = await supabase
|
||||
.from("checkups")
|
||||
.select(
|
||||
"id, customer_id, queue_number, status, heart_beat, blood_pressure, temperature, weight, height, symptoms, diagnosis, conclusion, notes, recheck_date",
|
||||
"id, customer_id, queue_number, status, heart_beat, blood_pressure, temperature, weight, height, symptoms, diagnosis, conclusion, notes, recheck_date, template_id, template_values",
|
||||
)
|
||||
.eq("id", checkupId)
|
||||
.eq("deleted", false)
|
||||
@@ -34,14 +35,35 @@ export default async function CheckupPage({
|
||||
|
||||
if (!c) notFound();
|
||||
|
||||
const { data: customer } = await supabase
|
||||
.from("customers")
|
||||
.select("last_name, first_name, dob, gender, phone")
|
||||
.eq("id", c.customer_id)
|
||||
.maybeSingle();
|
||||
const [{ data: customer }, { data: templates }] = 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 }),
|
||||
]);
|
||||
|
||||
const patientName = customer ? `${customer.last_name} ${customer.first_name}` : "—";
|
||||
|
||||
// Prefer templates matching the patient's gender (or applicable to "any");
|
||||
// keep the full list when the patient has no gender on file.
|
||||
const applicableTemplates = customer?.gender
|
||||
? (templates ?? []).filter((tpl) => tpl.gender === "any" || tpl.gender === customer.gender)
|
||||
: (templates ?? []);
|
||||
|
||||
const templateValues = Array.isArray(c.template_values)
|
||||
? (c.template_values as unknown[])
|
||||
.filter((v): v is { label: unknown; value: unknown } => !!v && typeof v === "object")
|
||||
.map((v) => ({ label: String(v.label ?? ""), value: String(v.value ?? "") }))
|
||||
: [];
|
||||
|
||||
const templateOptions = applicableTemplates.map((tpl) => ({
|
||||
id: tpl.id,
|
||||
name: tpl.name,
|
||||
labels: fieldsJsonToLabels(tpl.fields),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-10">
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
@@ -88,6 +110,9 @@ export default async function CheckupPage({
|
||||
recheckDate: str(c.recheck_date),
|
||||
status: c.status,
|
||||
}}
|
||||
templates={templateOptions}
|
||||
initialTemplateId={c.template_id}
|
||||
initialTemplateValues={templateValues}
|
||||
/>
|
||||
<p className="sr-only">{t("title")}</p>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,12 @@ 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";
|
||||
import { CheckupSaveSchema, parseNum, type CheckupSaveState } from "@/lib/checkups/checkup-schema";
|
||||
import {
|
||||
CheckupSaveSchema,
|
||||
parseNum,
|
||||
parseTemplateValues,
|
||||
type CheckupSaveState,
|
||||
} from "@/lib/checkups/checkup-schema";
|
||||
|
||||
const CLINICAL: AppRole[] = ["admin", "receptionist", "doctor", "nurse"];
|
||||
const isClinical = (r: AppRole | null | undefined) => !!r && CLINICAL.includes(r);
|
||||
@@ -55,6 +60,11 @@ export async function saveCheckupAction(
|
||||
}
|
||||
|
||||
const d = parsed.data;
|
||||
|
||||
const templateIdRaw = get("templateId").trim();
|
||||
const templateId = templateIdRaw && Number.isFinite(Number(templateIdRaw)) ? Number(templateIdRaw) : null;
|
||||
const templateValues = parseTemplateValues(get("templateValues"));
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const { error } = await supabase
|
||||
.from("checkups")
|
||||
@@ -70,6 +80,8 @@ export async function saveCheckupAction(
|
||||
notes: nullIfBlank(d.notes),
|
||||
recheck_date: nullIfBlank(d.recheckDate),
|
||||
status: d.status,
|
||||
template_id: templateId,
|
||||
template_values: templateValues,
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* (redirects to the queue on success).
|
||||
*/
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useActionState, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,13 +33,42 @@ export type CheckupDefaults = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export function CheckupForm({ defaults }: { defaults: CheckupDefaults }) {
|
||||
export type CheckupTemplateOption = { id: number; name: string; labels: string[] };
|
||||
type TemplateValue = { label: string; value: string };
|
||||
|
||||
export function CheckupForm({
|
||||
defaults,
|
||||
templates,
|
||||
initialTemplateId,
|
||||
initialTemplateValues,
|
||||
}: {
|
||||
defaults: CheckupDefaults;
|
||||
templates: CheckupTemplateOption[];
|
||||
initialTemplateId: number | null;
|
||||
initialTemplateValues: TemplateValue[];
|
||||
}) {
|
||||
const t = useTranslations("checkups");
|
||||
const [state, dispatch, isPending] = useActionState<CheckupSaveState, FormData>(saveCheckupAction, {
|
||||
status: "idle",
|
||||
});
|
||||
const formError = state.status === "error" ? state.formError : null;
|
||||
|
||||
// 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.
|
||||
const [templateId, setTemplateId] = useState(initialTemplateId != null ? String(initialTemplateId) : "");
|
||||
const [templateFieldValues, setTemplateFieldValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(initialTemplateValues.map((v) => [v.label, v.value])),
|
||||
);
|
||||
|
||||
const selectedTemplate = templates.find((tpl) => String(tpl.id) === templateId) ?? null;
|
||||
const templateValuesJson = JSON.stringify(
|
||||
(selectedTemplate?.labels ?? []).map((label) => ({
|
||||
label,
|
||||
value: templateFieldValues[label] ?? "",
|
||||
})),
|
||||
);
|
||||
|
||||
const textField = (name: keyof CheckupDefaults, label: string) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={name}>{label}</Label>
|
||||
@@ -75,6 +104,52 @@ export function CheckupForm({ defaults }: { defaults: CheckupDefaults }) {
|
||||
</fieldset>
|
||||
|
||||
{area("symptoms", t("symptoms"))}
|
||||
|
||||
{templates.length > 0 && (
|
||||
<fieldset className="border-border space-y-3 rounded-md border p-4">
|
||||
<legend className="text-muted-foreground px-1 text-xs">{t("template")}</legend>
|
||||
<input type="hidden" name="templateId" value={templateId} readOnly />
|
||||
<input type="hidden" name="templateValues" value={templateValuesJson} readOnly />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="templateSelect">{t("template")}</Label>
|
||||
<select
|
||||
id="templateSelect"
|
||||
value={templateId}
|
||||
disabled={isPending}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
className={`${CONTROL} h-10`}
|
||||
>
|
||||
<option value="">{t("templateNone")}</option>
|
||||
{templates.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedTemplate && selectedTemplate.labels.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<p className="text-muted-foreground text-xs sm:col-span-2">{t("templateFields")}</p>
|
||||
{selectedTemplate.labels.map((label) => (
|
||||
<div key={label} className="space-y-1.5">
|
||||
<Label htmlFor={`template-field-${label}`}>{label}</Label>
|
||||
<Input
|
||||
id={`template-field-${label}`}
|
||||
value={templateFieldValues[label] ?? ""}
|
||||
disabled={isPending}
|
||||
onChange={(e) =>
|
||||
setTemplateFieldValues((prev) => ({ ...prev, [label]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{area("diagnosis", t("diagnosis"))}
|
||||
{area("conclusion", t("conclusion"))}
|
||||
{area("notes", t("notes"))}
|
||||
|
||||
@@ -13,11 +13,19 @@ import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "@/lib/auth/get-server-session";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import type { AppRole } from "@/lib/db/roles";
|
||||
import { RegisterCheckupSchema, type RegisterCheckupState } from "@/lib/checkups/checkup-schema";
|
||||
import {
|
||||
RegisterCheckupSchema,
|
||||
SetQueueCounterSchema,
|
||||
type RegisterCheckupState,
|
||||
type SetQueueCounterState,
|
||||
} from "@/lib/checkups/checkup-schema";
|
||||
|
||||
const CLINICAL: AppRole[] = ["admin", "receptionist", "doctor", "nurse"];
|
||||
const isClinical = (r: AppRole | null | undefined) => !!r && CLINICAL.includes(r);
|
||||
|
||||
const COUNTER_MANAGERS: AppRole[] = ["admin", "receptionist"];
|
||||
const canManageCounter = (r: AppRole | null | undefined) => !!r && COUNTER_MANAGERS.includes(r);
|
||||
|
||||
export async function registerCheckupAction(
|
||||
_prev: RegisterCheckupState,
|
||||
formData: FormData,
|
||||
@@ -73,6 +81,53 @@ export async function registerCheckupAction(
|
||||
return { status: "success", queueNumber: row?.queue_number ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually sets today's queue counter for a shift (correcting a miscount,
|
||||
* resetting after a printer jam, etc). admin/receptionist only — RLS +
|
||||
* set_queue_counter's role check enforce it; this is defense-in-depth.
|
||||
*/
|
||||
export async function setQueueCounterAction(
|
||||
_prev: SetQueueCounterState,
|
||||
formData: FormData,
|
||||
): Promise<SetQueueCounterState> {
|
||||
const t = await getTranslations("queue");
|
||||
|
||||
const session = await getServerSession();
|
||||
if (!canManageCounter(session?.role)) {
|
||||
return { status: "error", formError: t("errorForbidden") };
|
||||
}
|
||||
|
||||
const parsed = SetQueueCounterSchema.safeParse({
|
||||
shiftId: formData.get("shiftId"),
|
||||
value: formData.get("value"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { status: "error", formError: t("errorGeneric") };
|
||||
}
|
||||
|
||||
const { shiftId, value } = parsed.data;
|
||||
const supabase = await createSupabaseServerClient();
|
||||
|
||||
const { error } = await supabase.rpc("set_queue_counter", {
|
||||
p_shift_id: shiftId,
|
||||
p_value: value,
|
||||
});
|
||||
if (error) {
|
||||
return { status: "error", formError: t("errorGeneric") };
|
||||
}
|
||||
|
||||
await supabase.rpc("log_audit", {
|
||||
p_action: "queue.set_counter",
|
||||
p_entity: "daily_queue_counters",
|
||||
p_entity_id: String(shiftId),
|
||||
p_details: { value },
|
||||
});
|
||||
|
||||
const locale = await getLocale();
|
||||
revalidatePath(`/${locale}/queue`);
|
||||
return { status: "success" };
|
||||
}
|
||||
|
||||
export async function callPatientAction(formData: FormData): Promise<void> {
|
||||
const session = await getServerSession();
|
||||
if (!isClinical(session?.role)) return;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Manual queue-counter override — one instance per shift. admin/receptionist
|
||||
* only (the queue page only renders this for those roles); posts to
|
||||
* setQueueCounterAction, which calls the set_queue_counter RPC.
|
||||
*/
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { SetQueueCounterState } from "@/lib/checkups/checkup-schema";
|
||||
import { setQueueCounterAction } from "./actions";
|
||||
|
||||
export function CounterForm({ shiftId, currentValue }: { shiftId: number; currentValue: number }) {
|
||||
const t = useTranslations("queue");
|
||||
const [state, dispatch, isPending] = useActionState<SetQueueCounterState, FormData>(
|
||||
setQueueCounterAction,
|
||||
{ status: "idle" },
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={dispatch} className="flex items-center gap-2">
|
||||
<input type="hidden" name="shiftId" value={shiftId} />
|
||||
<Input
|
||||
type="number"
|
||||
name="value"
|
||||
min={0}
|
||||
defaultValue={currentValue}
|
||||
disabled={isPending}
|
||||
aria-label={t("setCounter")}
|
||||
className="h-8 w-20 px-2 text-sm"
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={isPending}>
|
||||
{isPending ? t("saving") : t("setCounter")}
|
||||
</Button>
|
||||
{state.status === "success" && (
|
||||
<span className="text-xs font-medium text-green-600" role="status">
|
||||
{t("saved")}
|
||||
</span>
|
||||
)}
|
||||
{state.status === "error" && state.formError && (
|
||||
<span className="text-destructive text-xs" role="alert">
|
||||
{state.formError}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,15 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { createSupabaseServerClient } from "@/lib/supabase/server";
|
||||
import { getServerSession } from "@/lib/auth/get-server-session";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RegisterForm } from "./register-form";
|
||||
import { QueueRealtime } from "./queue-realtime";
|
||||
import { CounterForm } from "./counter-form";
|
||||
import { callPatientAction } from "./actions";
|
||||
|
||||
const COUNTER_MANAGERS = new Set(["admin", "receptionist"]);
|
||||
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
waiting: "bg-muted text-foreground",
|
||||
in_progress: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
|
||||
@@ -25,9 +29,11 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
|
||||
const tShift = await getTranslations("shifts");
|
||||
|
||||
const supabase = await createSupabaseServerClient();
|
||||
const session = await getServerSession();
|
||||
const canManageCounter = COUNTER_MANAGERS.has(session?.role ?? "");
|
||||
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh" }).format(new Date());
|
||||
|
||||
const [{ data: checkups }, { data: patients }, { data: shifts }, { data: doctors }] =
|
||||
const [{ data: checkups }, { data: patients }, { data: shifts }, { data: doctors }, { data: counters }] =
|
||||
await Promise.all([
|
||||
supabase
|
||||
.from("checkups")
|
||||
@@ -39,6 +45,7 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
|
||||
supabase.rpc("search_customers", { q: "" }),
|
||||
supabase.from("shifts").select("id, code").order("sort_order", { ascending: true }),
|
||||
supabase.from("doctors").select("id, last_name, first_name").eq("deleted", false).order("last_name"),
|
||||
supabase.from("daily_queue_counters").select("shift_id, last_number").eq("day", today),
|
||||
]);
|
||||
|
||||
const rows = checkups ?? [];
|
||||
@@ -50,12 +57,34 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
|
||||
const custName = new Map((custs ?? []).map((c) => [c.id, `${c.last_name} ${c.first_name}`]));
|
||||
const docName = new Map((doctors ?? []).map((d) => [d.id, `${d.last_name} ${d.first_name}`]));
|
||||
const shiftCode = new Map((shifts ?? []).map((s) => [s.id, s.code]));
|
||||
const counterByShift = new Map((counters ?? []).map((c) => [c.shift_id, c.last_number]));
|
||||
|
||||
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("counters")}</h2>
|
||||
<ul className="grid gap-3 sm:grid-cols-3">
|
||||
{(shifts ?? []).map((s) => (
|
||||
<li key={s.id} className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{tShift(s.code)} · {t("counter")}
|
||||
</p>
|
||||
<p className="text-foreground text-lg font-bold tabular-nums">
|
||||
{counterByShift.get(s.id) ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
{canManageCounter && (
|
||||
<CounterForm shiftId={s.id} currentValue={counterByShift.get(s.id) ?? 0} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="border-border mb-8 rounded-lg border p-4">
|
||||
<h2 className="text-foreground mb-3 text-sm font-medium">{t("registerTitle")}</h2>
|
||||
<RegisterForm
|
||||
|
||||
@@ -21,6 +21,18 @@ export type RegisterCheckupState =
|
||||
| { status: "error"; fieldErrors: Record<string, string[]>; formError: string | null }
|
||||
| { status: "success"; queueNumber: number | null };
|
||||
|
||||
// ── Manual queue-counter override (admin/receptionist) ───────────────────────
|
||||
export const SetQueueCounterSchema = z.object({
|
||||
shiftId: z.coerce.number().int().min(1),
|
||||
value: z.coerce.number().int().min(0),
|
||||
});
|
||||
export type SetQueueCounterInput = z.infer<typeof SetQueueCounterSchema>;
|
||||
|
||||
export type SetQueueCounterState =
|
||||
| { status: "idle" }
|
||||
| { status: "error"; formError: string | null }
|
||||
| { status: "success" };
|
||||
|
||||
// ── Doctor fills the checkup ─────────────────────────────────────────────────
|
||||
const dateOrEmpty = z
|
||||
.string()
|
||||
@@ -55,3 +67,34 @@ export function parseNum(s: string): number | null {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
// ── Applied checkup-template field values ────────────────────────────────────
|
||||
// Ordered [{ label, value }] snapshot of the template chosen on the checkup
|
||||
// form, serialized to a single hidden `templateValues` JSON input (same trick
|
||||
// as the prescription composer's line arrays).
|
||||
export const TemplateValuesSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
label: z.string().trim().min(1).max(500),
|
||||
value: z.string().max(500),
|
||||
}),
|
||||
)
|
||||
.max(50);
|
||||
export type TemplateValues = z.infer<typeof TemplateValuesSchema>;
|
||||
|
||||
/**
|
||||
* Parses the `templateValues` hidden-input JSON string. Invalid JSON or a
|
||||
* shape that fails validation is treated as "no template values" (null)
|
||||
* rather than failing the whole checkup save.
|
||||
*/
|
||||
export function parseTemplateValues(raw: string): TemplateValues | null {
|
||||
if (!raw.trim()) return null;
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const parsed = TemplateValuesSchema.safeParse(json);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
@@ -34,9 +34,13 @@ export function fieldsTextToJson(text: string): { label: string }[] {
|
||||
|
||||
/** Inverse: render a stored layout back to one-label-per-line text for editing. */
|
||||
export function fieldsJsonToText(fields: unknown): string {
|
||||
if (!Array.isArray(fields)) return "";
|
||||
return fieldsJsonToLabels(fields).join("\n");
|
||||
}
|
||||
|
||||
/** Extract just the ordered labels from a stored `fields` layout. */
|
||||
export function fieldsJsonToLabels(fields: unknown): string[] {
|
||||
if (!Array.isArray(fields)) return [];
|
||||
return fields
|
||||
.map((f) => (f && typeof f === "object" && "label" in f ? String((f as { label: unknown }).label) : ""))
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
+10
-2
@@ -141,7 +141,12 @@
|
||||
"open": "Open",
|
||||
"status": { "waiting": "Waiting", "in_progress": "In progress", "done": "Done" },
|
||||
"errorForbidden": "You do not have permission to manage the queue.",
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
"errorGeneric": "Something went wrong. Please try again.",
|
||||
"counters": "Shift counters",
|
||||
"counter": "Current number",
|
||||
"setCounter": "Set",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved."
|
||||
},
|
||||
"checkups": {
|
||||
"title": "Checkup",
|
||||
@@ -163,7 +168,10 @@
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Are you sure you want to delete this checkup?",
|
||||
"errorForbidden": "You do not have permission to edit checkups.",
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
"errorGeneric": "Something went wrong. Please try again.",
|
||||
"template": "Checkup template",
|
||||
"templateNone": "— No template —",
|
||||
"templateFields": "Template fields"
|
||||
},
|
||||
"billing": {
|
||||
"title": "Prescription & billing",
|
||||
|
||||
+10
-2
@@ -141,7 +141,12 @@
|
||||
"open": "Mở",
|
||||
"status": { "waiting": "Chờ khám", "in_progress": "Đang khám", "done": "Đã khám" },
|
||||
"errorForbidden": "Bạn không có quyền thao tác hàng chờ.",
|
||||
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại."
|
||||
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại.",
|
||||
"counters": "Số thứ tự các ca",
|
||||
"counter": "Số thứ tự hiện tại",
|
||||
"setCounter": "Đặt lại",
|
||||
"saving": "Đang lưu…",
|
||||
"saved": "Đã lưu."
|
||||
},
|
||||
"checkups": {
|
||||
"title": "Phiếu khám",
|
||||
@@ -163,7 +168,10 @@
|
||||
"delete": "Xóa",
|
||||
"confirmDelete": "Bạn có chắc chắn muốn xóa phiếu khám này không?",
|
||||
"errorForbidden": "Bạn không có quyền chỉnh sửa phiếu khám.",
|
||||
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại."
|
||||
"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"
|
||||
},
|
||||
"billing": {
|
||||
"title": "Đơn thuốc & thanh toán",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- BSK — closes the last two original-app feature gaps.
|
||||
--
|
||||
-- (a) Manual queue-counter control (orig SetCounterRequest / GetCounterRequest):
|
||||
-- admin/receptionist can set today's per-shift counter directly (e.g. to
|
||||
-- correct a miscount or reset after a printer jam) via set_queue_counter().
|
||||
-- daily_queue_counters was DEFINER-only (no SELECT policy); staff need to
|
||||
-- see the current numbers, so we add a read-only SELECT policy here.
|
||||
-- (b) Checkup templates are now actually applied: checkups.template_values
|
||||
-- stores the doctor's filled-in snapshot of the chosen template's fields.
|
||||
|
||||
-- ─── (a) set_queue_counter — manual counter override ────────────────────────
|
||||
CREATE OR REPLACE FUNCTION bsk.set_queue_counter(p_shift_id smallint, p_value integer)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
VOLATILE
|
||||
SECURITY DEFINER
|
||||
SET search_path = bsk, pg_catalog
|
||||
AS $$
|
||||
DECLARE
|
||||
v_role bsk.app_role := bsk.current_role();
|
||||
BEGIN
|
||||
IF v_role IS NULL OR v_role NOT IN ('admin', 'receptionist') THEN
|
||||
RAISE EXCEPTION 'not authorized to set the queue counter';
|
||||
END IF;
|
||||
|
||||
IF p_value < 0 THEN
|
||||
RAISE EXCEPTION 'counter value must not be negative';
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(hashtext('bsk:queue_counter')::bigint);
|
||||
|
||||
INSERT INTO bsk.daily_queue_counters (day, shift_id, last_number)
|
||||
VALUES ((now() AT TIME ZONE 'Asia/Ho_Chi_Minh')::date, p_shift_id, p_value)
|
||||
ON CONFLICT (day, shift_id) DO UPDATE SET last_number = EXCLUDED.last_number;
|
||||
END
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION bsk.set_queue_counter(smallint, integer) IS
|
||||
'Manually sets today''s (VN-local) queue counter for a shift. SECURITY DEFINER, '
|
||||
'role-gated to admin/receptionist. Advisory-locked against register_checkup''s '
|
||||
'concurrent increment.';
|
||||
|
||||
-- daily_queue_counters had RLS enabled with no policies (DEFINER-only writes).
|
||||
-- Staff need to see the current numbers, so add a read-only SELECT policy —
|
||||
-- writes still only happen through register_checkup() / set_queue_counter().
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'bsk' AND tablename = 'daily_queue_counters'
|
||||
AND policyname = 'daily_queue_counters_select_enrolled'
|
||||
) THEN
|
||||
CREATE POLICY daily_queue_counters_select_enrolled ON bsk.daily_queue_counters
|
||||
FOR SELECT USING (bsk.current_role() IS NOT NULL);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
GRANT SELECT ON bsk.daily_queue_counters TO authenticated; -- no INSERT/UPDATE/DELETE: DEFINER-only
|
||||
GRANT EXECUTE ON FUNCTION bsk.set_queue_counter(smallint, integer) TO authenticated;
|
||||
|
||||
-- ─── (b) checkups.template_values — applied template snapshot ───────────────
|
||||
ALTER TABLE bsk.checkups ADD COLUMN IF NOT EXISTS template_values jsonb;
|
||||
|
||||
COMMENT ON COLUMN bsk.checkups.template_values IS
|
||||
'Ordered [{"label": "...", "value": "..."}] snapshot of the applied '
|
||||
'checkup_templates.fields, filled in by the doctor. No new RLS policy needed: '
|
||||
'the existing checkups_update_clinical policy already covers this column.';
|
||||
@@ -69,6 +69,7 @@ export type Database = {
|
||||
symptoms: string | null;
|
||||
temperature: number | null;
|
||||
template_id: number | null;
|
||||
template_values: Json | null;
|
||||
updated_at: string;
|
||||
weight: number | null;
|
||||
};
|
||||
@@ -94,6 +95,7 @@ export type Database = {
|
||||
symptoms?: string | null;
|
||||
temperature?: number | null;
|
||||
template_id?: number | null;
|
||||
template_values?: Json | null;
|
||||
updated_at?: string;
|
||||
weight?: number | null;
|
||||
};
|
||||
@@ -119,11 +121,18 @@ export type Database = {
|
||||
symptoms?: string | null;
|
||||
temperature?: number | null;
|
||||
template_id?: number | null;
|
||||
template_values?: Json | null;
|
||||
updated_at?: string;
|
||||
weight?: number | null;
|
||||
};
|
||||
Relationships: [];
|
||||
};
|
||||
daily_queue_counters: {
|
||||
Row: { day: string; last_number: number; shift_id: number };
|
||||
Insert: { day?: string; last_number?: number; shift_id: number };
|
||||
Update: { day?: string; last_number?: number; shift_id?: number };
|
||||
Relationships: [];
|
||||
};
|
||||
shifts: {
|
||||
Row: { code: string; id: number; sort_order: number };
|
||||
Insert: { code: string; id: number; sort_order?: number };
|
||||
@@ -512,6 +521,10 @@ export type Database = {
|
||||
};
|
||||
Returns: number;
|
||||
};
|
||||
set_queue_counter: {
|
||||
Args: { p_shift_id: number; p_value: number };
|
||||
Returns: undefined;
|
||||
};
|
||||
set_staff_role: {
|
||||
Args: { p_user_id: string; p_role: Database["bsk"]["Enums"]["app_role"] };
|
||||
Returns: undefined;
|
||||
|
||||
Reference in New Issue
Block a user