feat(settings): clinic information management (Phase 2 slice)

- bsk.clinic_settings singleton (boolean PK pinned true) with name,
  address, phone, barcode prefix; seeded blank row
- RLS: enrolled staff read, admin write; user-client upsert so RLS gates
- admin settings page + form; audit-logged; sidebar nav entry; vi/en
This commit is contained in:
2026-07-25 02:30:42 +07:00
parent e2fb601b2a
commit b9ff387a6d
10 changed files with 337 additions and 3 deletions
@@ -0,0 +1,70 @@
"use server";
/**
* Clinic-settings Server Action (admin only). Upserts the singleton row via the
* caller's user client (RLS admin policy is the gate), audit-logs, revalidates.
* Empty strings are stored as NULL.
*/
import { getLocale, getTranslations } from "next-intl/server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "@/lib/auth/get-server-session";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import {
ClinicSettingsSchema,
type ClinicSettingsState,
} from "@/lib/clinic/clinic-settings-schema";
const emptyToNull = (s: string) => (s.length > 0 ? s : null);
export async function updateClinicSettingsAction(
_prev: ClinicSettingsState,
formData: FormData,
): Promise<ClinicSettingsState> {
const t = await getTranslations("admin.settings");
const session = await getServerSession();
if (session?.role !== "admin") {
return { status: "error", fieldErrors: {}, formError: t("errorForbidden") };
}
const parsed = ClinicSettingsSchema.safeParse({
name: formData.get("name"),
address: formData.get("address"),
phone: formData.get("phone"),
prefix: formData.get("prefix"),
});
if (!parsed.success) {
return {
status: "error",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
formError: null,
};
}
const { name, address, phone, prefix } = parsed.data;
const supabase = await createSupabaseServerClient();
const { error } = await supabase.from("clinic_settings").upsert(
{
id: true,
name: emptyToNull(name),
address: emptyToNull(address),
phone: emptyToNull(phone),
prefix: emptyToNull(prefix),
updated_at: new Date().toISOString(),
},
{ onConflict: "id" },
);
if (error) {
return { status: "error", fieldErrors: {}, formError: t("errorGeneric") };
}
await supabase.rpc("log_audit", { p_action: "clinic_settings.update", p_entity: "clinic_settings" });
const locale = await getLocale();
revalidatePath(`/${locale}/admin/settings`);
return { status: "success" };
}
@@ -0,0 +1,86 @@
"use client";
/**
* Clinic-settings form — Client Component. RHF (onBlur) + useActionState,
* prefilled from the current row. Same wiring as the other admin forms.
*/
import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ClinicSettingsSchema,
type ClinicSettingsInput,
type ClinicSettingsState,
} from "@/lib/clinic/clinic-settings-schema";
import { updateClinicSettingsAction } from "./actions";
const FIELDS = ["name", "address", "phone", "prefix"] as const;
export function ClinicSettingsForm({ defaults }: { defaults: ClinicSettingsInput }) {
const t = useTranslations("admin.settings");
const [state, dispatchAction, isPending] = useActionState<ClinicSettingsState, FormData>(
updateClinicSettingsAction,
{ status: "idle" },
);
const form = useForm<ClinicSettingsInput>({
resolver: zodResolver(ClinicSettingsSchema),
mode: "onBlur",
defaultValues: defaults,
});
const { errors } = form.formState;
useEffect(() => {
if (state.status !== "error") return;
for (const f of FIELDS) {
if (state.fieldErrors[f]?.length) form.setError(f, { message: state.fieldErrors[f][0] });
}
}, [state, form]);
const formError = state.status === "error" && state.formError ? state.formError : null;
return (
<form action={dispatchAction} noValidate className="space-y-4">
{FIELDS.map((f) => (
<div key={f} className="space-y-1.5">
<Label htmlFor={`clinic-${f}`}>{t(f)}</Label>
<Input
id={`clinic-${f}`}
disabled={isPending}
aria-invalid={!!errors[f]}
{...form.register(f)}
/>
{errors[f] && (
<p className="text-destructive text-sm" role="alert">
{errors[f]?.message}
</p>
)}
</div>
))}
<div className="flex items-center gap-3">
<Button type="submit" size="lg" disabled={isPending}>
{isPending ? t("submitting") : t("save")}
</Button>
{state.status === "success" && (
<p className="text-sm font-medium text-green-600" role="status">
{t("saved")}
</p>
)}
{formError && (
<p className="text-destructive text-sm" role="alert">
{formError}
</p>
)}
</div>
</form>
);
}
@@ -0,0 +1,40 @@
// WARNING: Do NOT add `'use cache'` — reads cookies() via the server client.
/**
* Clinic settings — Server Component (admin only; gated by (app)/admin layout).
* Reads the singleton row and hands its values to the client form.
*/
import { getTranslations } from "next-intl/server";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { ClinicSettingsForm } from "./clinic-settings-form";
export default async function ClinicSettingsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
await params; // Next.js 16: params is async.
const t = await getTranslations("admin.settings");
const supabase = await createSupabaseServerClient();
const { data } = await supabase
.from("clinic_settings")
.select("name, address, phone, prefix")
.eq("id", true)
.maybeSingle();
const defaults = {
name: data?.name ?? "",
address: data?.address ?? "",
phone: data?.phone ?? "",
prefix: data?.prefix ?? "",
};
return (
<div className="mx-auto max-w-md px-4 py-10">
<h1 className="text-foreground mb-6 text-xl font-semibold">{t("title")}</h1>
<ClinicSettingsForm defaults={defaults} />
</div>
);
}
+2 -1
View File
@@ -14,7 +14,7 @@
import type { AppRole } from "@/lib/db/roles";
import type { ComponentType } from "react";
import { LayoutDashboard, UserPlus, Stethoscope } from "lucide-react";
import { LayoutDashboard, UserPlus, Stethoscope, Settings } from "lucide-react";
export type MenuItem = {
/** Locale-relative path, e.g. "/dashboard". Sidebar prefixes with locale. */
@@ -40,6 +40,7 @@ export const ROLE_MENU: Record<AppRole, MenuItem[]> = {
{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard },
{ href: "/admin/doctors", labelKey: "nav.doctors", icon: Stethoscope },
{ href: "/admin/invite", labelKey: "nav.invite", icon: UserPlus },
{ href: "/admin/settings", labelKey: "nav.settings", icon: Settings },
],
doctor: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
nurse: [{ href: "/dashboard", labelKey: "nav.dashboard", icon: LayoutDashboard }],
+20
View File
@@ -0,0 +1,20 @@
/**
* Zod schema + state for clinic settings. Shared client (RHF) / server (action).
* All fields optional — a new clinic starts blank and fills these in.
*/
import { z } from "zod";
export const ClinicSettingsSchema = z.object({
name: z.string().trim().max(200),
address: z.string().trim().max(300),
phone: z.string().trim().max(30),
prefix: z.string().trim().max(20),
});
export type ClinicSettingsInput = z.infer<typeof ClinicSettingsSchema>;
export type ClinicSettingsState =
| { status: "idle" }
| { status: "error"; fieldErrors: Record<string, string[]>; formError: string | null }
| { status: "success" };
+12
View File
@@ -34,6 +34,18 @@
"empty": "No doctors yet.",
"errorForbidden": "You do not have permission to manage doctors.",
"errorGeneric": "Something went wrong. Please try again."
},
"settings": {
"title": "Clinic information",
"name": "Clinic name",
"address": "Address",
"phone": "Phone number",
"prefix": "Code prefix",
"save": "Save",
"submitting": "Saving…",
"saved": "Saved.",
"errorForbidden": "You do not have permission to edit clinic information.",
"errorGeneric": "Something went wrong. Please try again."
}
},
"roles": {
+12
View File
@@ -34,6 +34,18 @@
"empty": "Chưa có bác sĩ nào.",
"errorForbidden": "Bạn không có quyền quản lý bác sĩ.",
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại."
},
"settings": {
"title": "Thông tin phòng khám",
"name": "Tên phòng khám",
"address": "Địa chỉ",
"phone": "Số điện thoại",
"prefix": "Tiền tố mã",
"save": "Lưu",
"submitting": "Đang lưu…",
"saved": "Đã lưu.",
"errorForbidden": "Bạn không có quyền chỉnh sửa thông tin phòng khám.",
"errorGeneric": "Đã xảy ra lỗi. Vui lòng thử lại."
}
},
"roles": {
@@ -8,7 +8,7 @@
| # | Entity | Original commands | Status |
|---|---|---|---|
| 2a | **Doctors** | AddDoctor/EditDoctor/GetDoctorInfo/GetDoctorGeneralInfo | ✅ DONE (this session) |
| 2b | Clinic settings | ClinicInfoRequest + settings edit | TODO |
| 2b | Clinic settings | ClinicInfoRequest + settings edit | ✅ DONE (this session) |
| 2c | Patients (customers) | AddPatient/GetRecentPatient + **geo-lookup** (provinces/wards) + **accent-insensitive search** | TODO (needs geo seed + unaccent decision) |
| 2d | Checkup templates | Add/Edit/Delete/GetAllTemplates + **gender** field | TODO |
| 2e | Staff user management | AddUser/EditUser/GetAllUserInfo (extends app_users) | TODO |
@@ -21,8 +21,10 @@
- Nav: admin gets `/admin/doctors` (`nav.doctors`, Stethoscope). i18n `admin.doctors.*` both locales.
- Pattern established: **RLS-as-gate (user client) + getServerSession defense-in-depth + Zod + log_audit + revalidatePath**. Later slices mirror it.
## Decisions locked
- **2c**: seed full VN geo (`provinces` + `wards`) + enable Postgres `unaccent` for accent-insensitive patient search.
## Open decisions (before their slices)
- **2c geo data**: which VN provinces/wards dataset to seed (post-2025 merged administrative units?); accent search via Postgres `unaccent` extension vs a normalized generated column.
- **2c/2d**: `customers` full field set (CCCD, phone, DOB, gender, weight/height); `checkup_templates` field-layout storage (JSON `fields`) + gender.
- **2e**: staff management vs the existing invite flow — reconcile (invite creates auth+enrollment; edit/list/role-reassign is the new surface).
@@ -0,0 +1,64 @@
-- BSK Phase 2 — clinic settings (singleton).
--
-- Mirrors the original Clinic/ClinicInfo: name, address, phone, and the code
-- prefix used on printed barcodes. One row for the whole deployment.
-- Reads: any enrolled staff (shown in headers/reports). Writes: admin only.
-- ─── 1. Table (singleton via boolean PK pinned to true) ──────────────────────
CREATE TABLE IF NOT EXISTS bsk.clinic_settings (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
name text,
address text,
phone text,
prefix text,
updated_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE bsk.clinic_settings IS
'Single-row clinic profile (id is pinned true by the CHECK, so only one row '
'can ever exist). name/address/phone for report headers; prefix for barcodes.';
-- Seed the singleton so the settings form always has a row to update.
INSERT INTO bsk.clinic_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING;
-- ─── 2. RLS ────────────────────────────────────────────────────────────────
ALTER TABLE bsk.clinic_settings ENABLE ROW LEVEL SECURITY;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'bsk' AND tablename = 'clinic_settings'
AND policyname = 'clinic_settings_select_enrolled'
) THEN
CREATE POLICY clinic_settings_select_enrolled
ON bsk.clinic_settings FOR SELECT
USING (bsk.current_role() IS NOT NULL);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'bsk' AND tablename = 'clinic_settings'
AND policyname = 'clinic_settings_insert_admin'
) THEN
CREATE POLICY clinic_settings_insert_admin
ON bsk.clinic_settings FOR INSERT
WITH CHECK (bsk.current_role() = 'admin');
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE schemaname = 'bsk' AND tablename = 'clinic_settings'
AND policyname = 'clinic_settings_update_admin'
) THEN
CREATE POLICY clinic_settings_update_admin
ON bsk.clinic_settings FOR UPDATE
USING (bsk.current_role() = 'admin')
WITH CHECK (bsk.current_role() = 'admin');
END IF;
END
$$;
GRANT SELECT, INSERT, UPDATE ON bsk.clinic_settings TO authenticated;
+27
View File
@@ -46,6 +46,33 @@ export type Database = {
};
Relationships: [];
};
clinic_settings: {
Row: {
address: string | null;
id: boolean;
name: string | null;
phone: string | null;
prefix: string | null;
updated_at: string;
};
Insert: {
address?: string | null;
id?: boolean;
name?: string | null;
phone?: string | null;
prefix?: string | null;
updated_at?: string;
};
Update: {
address?: string | null;
id?: boolean;
name?: string | null;
phone?: string | null;
prefix?: string | null;
updated_at?: string;
};
Relationships: [];
};
doctors: {
Row: {
created_at: string;