diff --git a/app/[locale]/(app)/admin/settings/actions.ts b/app/[locale]/(app)/admin/settings/actions.ts new file mode 100644 index 0000000..c9ec061 --- /dev/null +++ b/app/[locale]/(app)/admin/settings/actions.ts @@ -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 { + 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, + 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" }; +} diff --git a/app/[locale]/(app)/admin/settings/clinic-settings-form.tsx b/app/[locale]/(app)/admin/settings/clinic-settings-form.tsx new file mode 100644 index 0000000..117443d --- /dev/null +++ b/app/[locale]/(app)/admin/settings/clinic-settings-form.tsx @@ -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( + updateClinicSettingsAction, + { status: "idle" }, + ); + + const form = useForm({ + 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 ( +
+ {FIELDS.map((f) => ( +
+ + + {errors[f] && ( +

+ {errors[f]?.message} +

+ )} +
+ ))} + +
+ + {state.status === "success" && ( +

+ {t("saved")} +

+ )} + {formError && ( +

+ {formError} +

+ )} +
+
+ ); +} diff --git a/app/[locale]/(app)/admin/settings/page.tsx b/app/[locale]/(app)/admin/settings/page.tsx new file mode 100644 index 0000000..08fb609 --- /dev/null +++ b/app/[locale]/(app)/admin/settings/page.tsx @@ -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 ( +
+

{t("title")}

+ +
+ ); +} diff --git a/lib/auth/role-menu.ts b/lib/auth/role-menu.ts index f013d0c..3a04239 100644 --- a/lib/auth/role-menu.ts +++ b/lib/auth/role-menu.ts @@ -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 = { { 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 }], diff --git a/lib/clinic/clinic-settings-schema.ts b/lib/clinic/clinic-settings-schema.ts new file mode 100644 index 0000000..a01f8f8 --- /dev/null +++ b/lib/clinic/clinic-settings-schema.ts @@ -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; + +export type ClinicSettingsState = + | { status: "idle" } + | { status: "error"; fieldErrors: Record; formError: string | null } + | { status: "success" }; diff --git a/messages/en.json b/messages/en.json index ac0b386..5f21903 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/vi.json b/messages/vi.json index 5eb678a..6425779 100644 --- a/messages/vi.json +++ b/messages/vi.json @@ -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": { diff --git a/plans/260725-0149-phase-2-core-entities/plan.md b/plans/260725-0149-phase-2-core-entities/plan.md index abdc0d8..d370641 100644 --- a/plans/260725-0149-phase-2-core-entities/plan.md +++ b/plans/260725-0149-phase-2-core-entities/plan.md @@ -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). diff --git a/supabase/migrations/20260725020000_bsk_clinic_settings.sql b/supabase/migrations/20260725020000_bsk_clinic_settings.sql new file mode 100644 index 0000000..268046f --- /dev/null +++ b/supabase/migrations/20260725020000_bsk_clinic_settings.sql @@ -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; diff --git a/types/supabase-bsk.ts b/types/supabase-bsk.ts index dbbc246..d8e2914 100644 --- a/types/supabase-bsk.ts +++ b/types/supabase-bsk.ts @@ -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;