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:
2026-07-25 15:05:20 +07:00
parent 86046ace4f
commit d91515c36e
12 changed files with 409 additions and 18 deletions
+31 -6
View File
@@ -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 -1
View File
@@ -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);
+77 -2
View File
@@ -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"))}
+56 -1
View File
@@ -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;
+51
View File
@@ -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>
);
}
+30 -1
View File
@@ -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
+43
View File
@@ -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;
}
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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.';
+13
View File
@@ -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;