diff --git a/app/[locale]/(app)/checkups/[id]/page.tsx b/app/[locale]/(app)/checkups/[id]/page.tsx index 39b1d80..37ae17a 100644 --- a/app/[locale]/(app)/checkups/[id]/page.tsx +++ b/app/[locale]/(app)/checkups/[id]/page.tsx @@ -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 (
@@ -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} />

{t("title")}

diff --git a/app/[locale]/(app)/checkups/actions.ts b/app/[locale]/(app)/checkups/actions.ts index c8ff5a3..fcd9159 100644 --- a/app/[locale]/(app)/checkups/actions.ts +++ b/app/[locale]/(app)/checkups/actions.ts @@ -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); diff --git a/app/[locale]/(app)/checkups/checkup-form.tsx b/app/[locale]/(app)/checkups/checkup-form.tsx index 68b20b5..d12e83c 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 } 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(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>(() => + 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) => (
@@ -75,6 +104,52 @@ export function CheckupForm({ defaults }: { defaults: CheckupDefaults }) { {area("symptoms", t("symptoms"))} + + {templates.length > 0 && ( +
+ {t("template")} + + + +
+ + +
+ + {selectedTemplate && selectedTemplate.labels.length > 0 && ( +
+

{t("templateFields")}

+ {selectedTemplate.labels.map((label) => ( +
+ + + setTemplateFieldValues((prev) => ({ ...prev, [label]: e.target.value })) + } + /> +
+ ))} +
+ )} +
+ )} + {area("diagnosis", t("diagnosis"))} {area("conclusion", t("conclusion"))} {area("notes", t("notes"))} diff --git a/app/[locale]/(app)/queue/actions.ts b/app/[locale]/(app)/queue/actions.ts index 4a7f6da..cd42717 100644 --- a/app/[locale]/(app)/queue/actions.ts +++ b/app/[locale]/(app)/queue/actions.ts @@ -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 { + 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 { const session = await getServerSession(); if (!isClinical(session?.role)) return; diff --git a/app/[locale]/(app)/queue/counter-form.tsx b/app/[locale]/(app)/queue/counter-form.tsx new file mode 100644 index 0000000..9562e4b --- /dev/null +++ b/app/[locale]/(app)/queue/counter-form.tsx @@ -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( + setQueueCounterAction, + { status: "idle" }, + ); + + return ( +
+ + + + {state.status === "success" && ( + + {t("saved")} + + )} + {state.status === "error" && state.formError && ( + + {state.formError} + + )} +
+ ); +} diff --git a/app/[locale]/(app)/queue/page.tsx b/app/[locale]/(app)/queue/page.tsx index 7391e02..eef1f3e 100644 --- a/app/[locale]/(app)/queue/page.tsx +++ b/app/[locale]/(app)/queue/page.tsx @@ -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 = { 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 (

{t("title")}

+
+

{t("counters")}

+
    + {(shifts ?? []).map((s) => ( +
  • +
    +

    + {tShift(s.code)} · {t("counter")} +

    +

    + {counterByShift.get(s.id) ?? 0} +

    +
    + {canManageCounter && ( + + )} +
  • + ))} +
+
+

{t("registerTitle")}

; 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; + +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; + +/** + * 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; +} diff --git a/lib/templates/template-schema.ts b/lib/templates/template-schema.ts index ad216b3..f390699 100644 --- a/lib/templates/template-schema.ts +++ b/lib/templates/template-schema.ts @@ -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); } diff --git a/messages/en.json b/messages/en.json index d0c32ae..040b729 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/vi.json b/messages/vi.json index 0f11374..0bc60aa 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -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", diff --git a/supabase/migrations/20260725150000_bsk_counter_and_template_values.sql b/supabase/migrations/20260725150000_bsk_counter_and_template_values.sql new file mode 100644 index 0000000..cfc61a7 --- /dev/null +++ b/supabase/migrations/20260725150000_bsk_counter_and_template_values.sql @@ -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.'; diff --git a/types/supabase-bsk.ts b/types/supabase-bsk.ts index f999905..dbd89d0 100644 --- a/types/supabase-bsk.ts +++ b/types/supabase-bsk.ts @@ -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;