mirror of
https://github.com/tiennm99/bsk.git
synced 2026-08-05 16:23:38 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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" };
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user