diff --git a/README.md b/README.md index 222208d..f852b06 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ After provisioning Supabase/Upstash/Vercel and `npm run db:push`: ```sql INSERT INTO bsk.admin_allowlist (email) VALUES ('you@example.com'); ``` -2. **Seed Vietnamese geo data** (province/ward address dropdowns): `npm run db:seed-geo` (see `scripts/seed-geo.ts`). +2. **Seed Vietnamese geo data** (province/ward address dropdowns): `npm run db:seed-geo` (see `scripts/seed-geo.mjs`). 3. **Enable Supabase Realtime** on `bsk.checkups` (Database → Replication) for the live queue. 4. **Set `CRON_SECRET`** in Vercel so the nightly media-retention sweep (`/api/cron/nightly`, scheduled in `vercel.json`) can authenticate. @@ -34,7 +34,7 @@ See [docs/supabase-shared-config.md](./docs/supabase-shared-config.md) for the s ## Stack -- **npm** + **Next.js 16** (App Router) + **TypeScript** +- **npm** + **Next.js 16** (App Router) + **JavaScript with JSDoc types** (checked by tsc) - **Supabase** (Postgres + Auth + Storage) — shared across personal projects via schema-per-app - **Upstash** Redis + QStash — shared across personal projects via key prefixes - **Vercel** for hosting @@ -48,7 +48,7 @@ See [docs/supabase-shared-config.md](./docs/supabase-shared-config.md) for the s ## Database -After `npm run db:push`, run `npm run db:gen-types` to refresh `types/supabase-bsk.ts`. +After `npm run db:push`, run `npm run db:gen-types` to refresh `types/supabase-bsk.d.ts`. ## License diff --git a/app/[locale]/(app)/admin/doctors/actions.ts b/app/[locale]/(app)/admin/doctors/actions.js similarity index 84% rename from app/[locale]/(app)/admin/doctors/actions.ts rename to app/[locale]/(app)/admin/doctors/actions.js index f86a571..956e200 100644 --- a/app/[locale]/(app)/admin/doctors/actions.ts +++ b/app/[locale]/(app)/admin/doctors/actions.js @@ -15,7 +15,9 @@ import { revalidatePath } from "next/cache"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; -import { DoctorSchema, type DoctorFormState } from "@/lib/doctors/doctor-schema"; +import { DoctorSchema } from "@/lib/doctors/doctor-schema"; + +/** @typedef {import('@/lib/doctors/doctor-schema').DoctorFormState} DoctorFormState */ async function revalidateDoctors() { const locale = await getLocale(); @@ -23,10 +25,12 @@ async function revalidateDoctors() { } // ── Create (useActionState-compatible) ─────────────────────────────────────── -export async function createDoctorAction( - _prev: DoctorFormState, - formData: FormData, -): Promise { +/** + * @param {DoctorFormState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function createDoctorAction(_prev, formData) { const t = await getTranslations("admin.doctors"); const session = await getServerSession(); @@ -41,7 +45,7 @@ export async function createDoctorAction( if (!parsed.success) { return { status: "error", - fieldErrors: parsed.error.flatten().fieldErrors as Record, + fieldErrors: /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: null, }; } @@ -70,7 +74,11 @@ export async function createDoctorAction( } // ── Update ─────────────────────────────────────────────────────────────────── -export async function updateDoctorAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function updateDoctorAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; @@ -98,7 +106,11 @@ export async function updateDoctorAction(formData: FormData): Promise { } // ── Soft-delete (deactivate) ───────────────────────────────────────────────── -export async function deactivateDoctorAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function deactivateDoctorAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; diff --git a/app/[locale]/(app)/admin/doctors/add-doctor-form.tsx b/app/[locale]/(app)/admin/doctors/add-doctor-form.jsx similarity index 87% rename from app/[locale]/(app)/admin/doctors/add-doctor-form.tsx rename to app/[locale]/(app)/admin/doctors/add-doctor-form.jsx index d551a53..e39aceb 100644 --- a/app/[locale]/(app)/admin/doctors/add-doctor-form.tsx +++ b/app/[locale]/(app)/admin/doctors/add-doctor-form.jsx @@ -14,18 +14,21 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { DoctorSchema, type DoctorInput, type DoctorFormState } from "@/lib/doctors/doctor-schema"; +import { DoctorSchema } from "@/lib/doctors/doctor-schema"; import { createDoctorAction } from "./actions"; +/** @typedef {import('@/lib/doctors/doctor-schema').DoctorInput} DoctorInput */ +/** @typedef {import('@/lib/doctors/doctor-schema').DoctorFormState} DoctorFormState */ + +/** Admin form to add a doctor row. @returns {import("react").JSX.Element} */ export function AddDoctorForm() { const t = useTranslations("admin.doctors"); - const [state, dispatchAction, isPending] = useActionState( - createDoctorAction, - { status: "idle" }, - ); + const [state, dispatchAction, isPending] = useActionState(createDoctorAction, { + status: "idle", + }); - const form = useForm({ + const form = useForm({ resolver: zodResolver(DoctorSchema), mode: "onBlur", defaultValues: { firstName: "", lastName: "" }, diff --git a/app/[locale]/(app)/admin/doctors/page.tsx b/app/[locale]/(app)/admin/doctors/page.jsx similarity index 94% rename from app/[locale]/(app)/admin/doctors/page.tsx rename to app/[locale]/(app)/admin/doctors/page.jsx index 7499438..317308e 100644 --- a/app/[locale]/(app)/admin/doctors/page.tsx +++ b/app/[locale]/(app)/admin/doctors/page.jsx @@ -14,7 +14,11 @@ import { Input } from "@/components/ui/input"; import { AddDoctorForm } from "./add-doctor-form"; import { updateDoctorAction, deactivateDoctorAction } from "./actions"; -export default async function DoctorsPage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function DoctorsPage({ params }) { await params; // Next.js 16: params is async. const t = await getTranslations("admin.doctors"); diff --git a/app/[locale]/(app)/admin/invite/actions.ts b/app/[locale]/(app)/admin/invite/actions.js similarity index 90% rename from app/[locale]/(app)/admin/invite/actions.ts rename to app/[locale]/(app)/admin/invite/actions.js index c1f2ed8..e23c3e9 100644 --- a/app/[locale]/(app)/admin/invite/actions.ts +++ b/app/[locale]/(app)/admin/invite/actions.js @@ -24,15 +24,19 @@ import { getTranslations } from "next-intl/server"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseAdminClient } from "@/lib/supabase/admin"; import { createRateLimiter } from "@/lib/upstash"; -import { InviteUserSchema, type InviteUserState } from "@/lib/auth/invite-schema"; +import { InviteUserSchema } from "@/lib/auth/invite-schema"; + +/** @typedef {import('@/lib/auth/invite-schema').InviteUserState} InviteUserState */ // Bound SMTP spend on the SHARED project: cap invites per admin. 20 / hour. const inviteLimiter = createRateLimiter("invite", 20, 3600); -export async function inviteUserAction( - _prevState: InviteUserState, - formData: FormData, -): Promise { +/** + * @param {InviteUserState} _prevState + * @param {FormData} formData + * @returns {Promise} + */ +export async function inviteUserAction(_prevState, formData) { const t = await getTranslations("admin.invite"); // ── Caller-role check (defense-in-depth) ────────────────────────────────── @@ -59,7 +63,7 @@ export async function inviteUserAction( const flat = parsed.error.flatten(); return { status: "error", - fieldErrors: flat.fieldErrors as Record, + fieldErrors: /** @type {Record} */ (flat.fieldErrors), formError: null, }; } @@ -75,7 +79,7 @@ export async function inviteUserAction( // An existing email (often a sibling-app user on the shared auth pool) // makes inviteUserByEmail fail — report it as "already enrolled/known" // rather than a generic error so the admin understands what happened. - const code = (inviteError as { code?: string } | null)?.code; + const code = /** @type {{ code?: string } | null} */ (inviteError)?.code; const msg = inviteError?.message?.toLowerCase() ?? ""; const emailExists = code === "email_exists" || diff --git a/app/[locale]/(app)/admin/invite/invite-user-form.tsx b/app/[locale]/(app)/admin/invite/invite-user-form.jsx similarity index 89% rename from app/[locale]/(app)/admin/invite/invite-user-form.tsx rename to app/[locale]/(app)/admin/invite/invite-user-form.jsx index 820052f..59d0c5d 100644 --- a/app/[locale]/(app)/admin/invite/invite-user-form.tsx +++ b/app/[locale]/(app)/admin/invite/invite-user-form.jsx @@ -23,30 +23,29 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { appRoles } from "@/lib/db/roles"; -import { - InviteUserSchema, - type InviteUserInput, - type InviteUserState, -} from "@/lib/auth/invite-schema"; +import { InviteUserSchema } from "@/lib/auth/invite-schema"; import { inviteUserAction } from "./actions"; +/** @typedef {import('@/lib/auth/invite-schema').InviteUserInput} InviteUserInput */ +/** @typedef {import('@/lib/auth/invite-schema').InviteUserState} InviteUserState */ + // --------------------------------------------------------------------------- // Component // --------------------------------------------------------------------------- +/** Admin form that invites a user by email with a role. @returns {import("react").JSX.Element} */ export function InviteUserForm() { const t = useTranslations("admin.invite"); const tRoles = useTranslations("roles"); - const [state, dispatchAction, isPending] = useActionState( - inviteUserAction, - { status: "idle" }, - ); + const [state, dispatchAction, isPending] = useActionState(inviteUserAction, { + status: "idle", + }); - const form = useForm({ + const form = useForm({ resolver: zodResolver(InviteUserSchema), mode: "onBlur", - defaultValues: { email: "", role: "patient" }, + defaultValues: { email: "", role: /** @type {InviteUserInput["role"]} */ ("patient") }, }); const { errors: fieldErrors } = form.formState; diff --git a/app/[locale]/(app)/admin/invite/page.tsx b/app/[locale]/(app)/admin/invite/page.jsx similarity index 93% rename from app/[locale]/(app)/admin/invite/page.tsx rename to app/[locale]/(app)/admin/invite/page.jsx index 9563921..2cc0040 100644 --- a/app/[locale]/(app)/admin/invite/page.tsx +++ b/app/[locale]/(app)/admin/invite/page.jsx @@ -12,6 +12,7 @@ import { getTranslations } from "next-intl/server"; import { InviteUserForm } from "./invite-user-form"; +/** @returns {Promise} */ export default async function AdminInvitePage() { const t = await getTranslations("admin.invite"); diff --git a/app/[locale]/(app)/admin/layout.tsx b/app/[locale]/(app)/admin/layout.jsx similarity index 75% rename from app/[locale]/(app)/admin/layout.tsx rename to app/[locale]/(app)/admin/layout.jsx index e326549..7d36713 100644 --- a/app/[locale]/(app)/admin/layout.tsx +++ b/app/[locale]/(app)/admin/layout.jsx @@ -11,16 +11,13 @@ * receiving a 404, which would confirm that restricted admin routes exist. */ -import type { ReactNode } from "react"; import { requireRole } from "@/lib/auth/require-role"; -export default async function AdminLayout({ - children, - params, -}: { - children: ReactNode; - params: Promise<{ locale: string }>; -}) { +/** + * @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function AdminLayout({ children, params }) { const { locale } = await params; // Redirects to /dashboard if role !== 'admin'. diff --git a/app/[locale]/(app)/admin/medicines/[id]/edit/page.tsx b/app/[locale]/(app)/admin/medicines/[id]/edit/page.jsx similarity index 87% rename from app/[locale]/(app)/admin/medicines/[id]/edit/page.tsx rename to app/[locale]/(app)/admin/medicines/[id]/edit/page.jsx index 894c2f6..e3372d0 100644 --- a/app/[locale]/(app)/admin/medicines/[id]/edit/page.tsx +++ b/app/[locale]/(app)/admin/medicines/[id]/edit/page.jsx @@ -4,11 +4,11 @@ import { createSupabaseServerClient } from "@/lib/supabase/server"; import { MedicineForm } from "../../medicine-form"; import { updateMedicineAction } from "../../actions"; -export default async function EditMedicinePage({ - params, -}: { - params: Promise<{ locale: string; id: string }>; -}) { +/** + * @param {{ params: Promise<{ locale: string, id: string }> }} props + * @returns {Promise} + */ +export default async function EditMedicinePage({ params }) { const { id } = await params; const t = await getTranslations("admin.medicines"); const medId = Number(id); diff --git a/app/[locale]/(app)/admin/medicines/actions.ts b/app/[locale]/(app)/admin/medicines/actions.js similarity index 74% rename from app/[locale]/(app)/admin/medicines/actions.ts rename to app/[locale]/(app)/admin/medicines/actions.js index 3a5fa77..2063405 100644 --- a/app/[locale]/(app)/admin/medicines/actions.ts +++ b/app/[locale]/(app)/admin/medicines/actions.js @@ -8,14 +8,15 @@ import { revalidatePath } from "next/cache"; import { redirect } from "@/i18n/navigation"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; -import { - MedicineSchema, - type MedicineFormState, - type MedicineInput, -} from "@/lib/catalog/medicine-schema"; +import { MedicineSchema } from "@/lib/catalog/medicine-schema"; -function readForm(formData: FormData) { - const get = (k: string) => String(formData.get(k) ?? ""); +/** @typedef {import('@/lib/catalog/medicine-schema').MedicineFormState} MedicineFormState */ +/** @typedef {import('@/lib/catalog/medicine-schema').MedicineInput} MedicineInput */ + +/** @param {FormData} formData */ +function readForm(formData) { + /** @param {string} k */ + const get = (k) => String(formData.get(k) ?? ""); return { name: get("name"), unit: get("unit"), @@ -26,7 +27,8 @@ function readForm(formData: FormData) { }; } -function toRow(d: MedicineInput) { +/** @param {MedicineInput} d */ +function toRow(d) { const cost = d.costPrice.trim() ? Number(d.costPrice) : null; return { name: d.name, @@ -38,16 +40,19 @@ function toRow(d: MedicineInput) { }; } -async function finish(): Promise { +/** @returns {Promise} */ +async function finish() { const locale = await getLocale(); revalidatePath(`/${locale}/admin/medicines`); return redirect({ href: `/${locale}/admin/medicines`, locale }); } -export async function createMedicineAction( - _prev: MedicineFormState, - formData: FormData, -): Promise { +/** + * @param {MedicineFormState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function createMedicineAction(_prev, formData) { const t = await getTranslations("admin.medicines"); const session = await getServerSession(); if (session?.role !== "admin") @@ -57,7 +62,7 @@ export async function createMedicineAction( if (!parsed.success) { return { status: "error", - fieldErrors: parsed.error.flatten().fieldErrors as Record, + fieldErrors: /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: null, }; } @@ -78,10 +83,12 @@ export async function createMedicineAction( return finish(); } -export async function updateMedicineAction( - _prev: MedicineFormState, - formData: FormData, -): Promise { +/** + * @param {MedicineFormState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function updateMedicineAction(_prev, formData) { const t = await getTranslations("admin.medicines"); const session = await getServerSession(); if (session?.role !== "admin") @@ -94,7 +101,7 @@ export async function updateMedicineAction( status: "error", fieldErrors: parsed.success ? {} - : (parsed.error.flatten().fieldErrors as Record), + : /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: parsed.success ? t("errorGeneric") : null, }; } @@ -111,7 +118,11 @@ export async function updateMedicineAction( return finish(); } -export async function deactivateMedicineAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function deactivateMedicineAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; diff --git a/app/[locale]/(app)/admin/medicines/medicine-form.tsx b/app/[locale]/(app)/admin/medicines/medicine-form.jsx similarity index 68% rename from app/[locale]/(app)/admin/medicines/medicine-form.tsx rename to app/[locale]/(app)/admin/medicines/medicine-form.jsx index 87fc0fe..0ff25f7 100644 --- a/app/[locale]/(app)/admin/medicines/medicine-form.tsx +++ b/app/[locale]/(app)/admin/medicines/medicine-form.jsx @@ -8,35 +8,41 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { MedicineFormState } from "@/lib/catalog/medicine-schema"; -export type MedicineDefaults = { - id?: number; - name: string; - unit: string; - salePrice: string; - costPrice: string; - company: string; - route: string; -}; +/** @typedef {import('@/lib/catalog/medicine-schema').MedicineFormState} MedicineFormState */ -export function MedicineForm({ - mode, - action, - defaults, -}: { - mode: "create" | "edit"; - action: (prev: MedicineFormState, formData: FormData) => Promise; - defaults: MedicineDefaults; -}) { +/** + * @typedef {Object} MedicineDefaults + * @property {number} [id] + * @property {string} name + * @property {string} unit + * @property {string} salePrice + * @property {string} costPrice + * @property {string} company + * @property {string} route + */ + +/** + * @param {{ + * mode: "create" | "edit", + * action: (prev: MedicineFormState, formData: FormData) => Promise, + * defaults: MedicineDefaults, + * }} props + */ +export function MedicineForm({ mode, action, defaults }) { const t = useTranslations("admin.medicines"); - const [state, dispatch, isPending] = useActionState(action, { + const [state, dispatch, isPending] = useActionState(action, { status: "idle", }); const fe = state.status === "error" ? state.fieldErrors : {}; const formError = state.status === "error" ? state.formError : null; - const field = (name: keyof MedicineDefaults, label: string, type = "text") => ( + /** + * @param {keyof MedicineDefaults} name + * @param {string} label + * @param {string} [type] + */ + const field = (name, label, type = "text") => (
}) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function NewMedicinePage({ params }) { await params; const t = await getTranslations("admin.medicines"); return ( diff --git a/app/[locale]/(app)/admin/medicines/page.tsx b/app/[locale]/(app)/admin/medicines/page.jsx similarity index 80% rename from app/[locale]/(app)/admin/medicines/page.tsx rename to app/[locale]/(app)/admin/medicines/page.jsx index 7ad849e..f51bb45 100644 --- a/app/[locale]/(app)/admin/medicines/page.tsx +++ b/app/[locale]/(app)/admin/medicines/page.jsx @@ -6,9 +6,14 @@ import { createSupabaseServerClient } from "@/lib/supabase/server"; import { Button } from "@/components/ui/button"; import { deactivateMedicineAction } from "./actions"; -const vnd = (n: number) => `${new Intl.NumberFormat("vi-VN").format(n)} ₫`; +/** @param {number} n */ +const vnd = (n) => `${new Intl.NumberFormat("vi-VN").format(n)} ₫`; -export default async function MedicinesPage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function MedicinesPage({ params }) { const { locale } = await params; const t = await getTranslations("admin.medicines"); @@ -25,7 +30,7 @@ export default async function MedicinesPage({ params }: { params: Promise<{ loca

{t("title")}

@@ -45,7 +50,10 @@ export default async function MedicinesPage({ params }: { params: Promise<{ loca
diff --git a/app/[locale]/(app)/admin/reports/page.tsx b/app/[locale]/(app)/admin/reports/page.jsx similarity index 94% rename from app/[locale]/(app)/admin/reports/page.tsx rename to app/[locale]/(app)/admin/reports/page.jsx index 2456196..9c24d7b 100644 --- a/app/[locale]/(app)/admin/reports/page.tsx +++ b/app/[locale]/(app)/admin/reports/page.jsx @@ -8,7 +8,11 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { routing } from "@/i18n/routing"; -export default async function ReportsPage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function ReportsPage({ params }) { const { locale } = await params; const t = await getTranslations("reports"); diff --git a/app/[locale]/(app)/admin/services/actions.ts b/app/[locale]/(app)/admin/services/actions.js similarity index 79% rename from app/[locale]/(app)/admin/services/actions.ts rename to app/[locale]/(app)/admin/services/actions.js index d5e26b4..a74e17a 100644 --- a/app/[locale]/(app)/admin/services/actions.ts +++ b/app/[locale]/(app)/admin/services/actions.js @@ -7,17 +7,21 @@ import { revalidatePath } from "next/cache"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; -import { ServiceSchema, type ServiceFormState } from "@/lib/catalog/service-schema"; +import { ServiceSchema } from "@/lib/catalog/service-schema"; + +/** @typedef {import('@/lib/catalog/service-schema').ServiceFormState} ServiceFormState */ async function revalidateServices() { const locale = await getLocale(); revalidatePath(`/${locale}/admin/services`); } -export async function createServiceAction( - _prev: ServiceFormState, - formData: FormData, -): Promise { +/** + * @param {ServiceFormState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function createServiceAction(_prev, formData) { const t = await getTranslations("admin.services"); const session = await getServerSession(); if (session?.role !== "admin") @@ -30,7 +34,7 @@ export async function createServiceAction( if (!parsed.success) { return { status: "error", - fieldErrors: parsed.error.flatten().fieldErrors as Record, + fieldErrors: /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: null, }; } @@ -52,7 +56,11 @@ export async function createServiceAction( return { status: "success", serviceName: parsed.data.name }; } -export async function updateServiceAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function updateServiceAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; @@ -78,7 +86,11 @@ export async function updateServiceAction(formData: FormData): Promise { } } -export async function deactivateServiceAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function deactivateServiceAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; diff --git a/app/[locale]/(app)/admin/services/add-service-form.tsx b/app/[locale]/(app)/admin/services/add-service-form.jsx similarity index 85% rename from app/[locale]/(app)/admin/services/add-service-form.tsx rename to app/[locale]/(app)/admin/services/add-service-form.jsx index 417e4a4..dfd467d 100644 --- a/app/[locale]/(app)/admin/services/add-service-form.tsx +++ b/app/[locale]/(app)/admin/services/add-service-form.jsx @@ -12,18 +12,17 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import type { ServiceFormState } from "@/lib/catalog/service-schema"; import { createServiceAction } from "./actions"; +/** @typedef {import('@/lib/catalog/service-schema').ServiceFormState} ServiceFormState */ + +/** Admin form to add a service row. @returns {import("react").JSX.Element} */ export function AddServiceForm() { const t = useTranslations("admin.services"); - const formRef = useRef(null); - const [state, dispatch, isPending] = useActionState( - createServiceAction, - { - status: "idle", - }, - ); + const formRef = useRef(/** @type {HTMLFormElement | null} */ (null)); + const [state, dispatch, isPending] = useActionState(createServiceAction, { + status: "idle", + }); useEffect(() => { if (state.status === "success") formRef.current?.reset(); diff --git a/app/[locale]/(app)/admin/services/page.tsx b/app/[locale]/(app)/admin/services/page.jsx similarity index 94% rename from app/[locale]/(app)/admin/services/page.tsx rename to app/[locale]/(app)/admin/services/page.jsx index 0cf9e4e..5ca5799 100644 --- a/app/[locale]/(app)/admin/services/page.tsx +++ b/app/[locale]/(app)/admin/services/page.jsx @@ -7,7 +7,11 @@ import { Input } from "@/components/ui/input"; import { AddServiceForm } from "./add-service-form"; import { updateServiceAction, deactivateServiceAction } from "./actions"; -export default async function ServicesPage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function ServicesPage({ params }) { await params; const t = await getTranslations("admin.services"); diff --git a/app/[locale]/(app)/admin/settings/actions.ts b/app/[locale]/(app)/admin/settings/actions.js similarity index 76% rename from app/[locale]/(app)/admin/settings/actions.ts rename to app/[locale]/(app)/admin/settings/actions.js index 6698aa5..02267e8 100644 --- a/app/[locale]/(app)/admin/settings/actions.ts +++ b/app/[locale]/(app)/admin/settings/actions.js @@ -11,17 +11,19 @@ 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"; +import { ClinicSettingsSchema } from "@/lib/clinic/clinic-settings-schema"; -const emptyToNull = (s: string) => (s.length > 0 ? s : null); +/** @typedef {import('@/lib/clinic/clinic-settings-schema').ClinicSettingsState} ClinicSettingsState */ -export async function updateClinicSettingsAction( - _prev: ClinicSettingsState, - formData: FormData, -): Promise { +/** @param {string} s */ +const emptyToNull = (s) => (s.length > 0 ? s : null); + +/** + * @param {ClinicSettingsState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function updateClinicSettingsAction(_prev, formData) { const t = await getTranslations("admin.settings"); const session = await getServerSession(); @@ -38,7 +40,7 @@ export async function updateClinicSettingsAction( if (!parsed.success) { return { status: "error", - fieldErrors: parsed.error.flatten().fieldErrors as Record, + fieldErrors: /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: null, }; } diff --git a/app/[locale]/(app)/admin/settings/clinic-settings-form.tsx b/app/[locale]/(app)/admin/settings/clinic-settings-form.jsx similarity index 77% rename from app/[locale]/(app)/admin/settings/clinic-settings-form.tsx rename to app/[locale]/(app)/admin/settings/clinic-settings-form.jsx index 117443d..16d6274 100644 --- a/app/[locale]/(app)/admin/settings/clinic-settings-form.tsx +++ b/app/[locale]/(app)/admin/settings/clinic-settings-form.jsx @@ -13,24 +13,23 @@ 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 { ClinicSettingsSchema } from "@/lib/clinic/clinic-settings-schema"; import { updateClinicSettingsAction } from "./actions"; -const FIELDS = ["name", "address", "phone", "prefix"] as const; +/** @typedef {import('@/lib/clinic/clinic-settings-schema').ClinicSettingsInput} ClinicSettingsInput */ +/** @typedef {import('@/lib/clinic/clinic-settings-schema').ClinicSettingsState} ClinicSettingsState */ -export function ClinicSettingsForm({ defaults }: { defaults: ClinicSettingsInput }) { +const FIELDS = /** @type {const} */ (["name", "address", "phone", "prefix"]); + +/** @param {{ defaults: ClinicSettingsInput }} props */ +export function ClinicSettingsForm({ defaults }) { const t = useTranslations("admin.settings"); - const [state, dispatchAction, isPending] = useActionState( - updateClinicSettingsAction, - { status: "idle" }, - ); + const [state, dispatchAction, isPending] = useActionState(updateClinicSettingsAction, { + status: "idle", + }); - const form = useForm({ + const form = useForm({ resolver: zodResolver(ClinicSettingsSchema), mode: "onBlur", defaultValues: defaults, diff --git a/app/[locale]/(app)/admin/settings/page.tsx b/app/[locale]/(app)/admin/settings/page.jsx similarity index 85% rename from app/[locale]/(app)/admin/settings/page.tsx rename to app/[locale]/(app)/admin/settings/page.jsx index 08fb609..0efbc64 100644 --- a/app/[locale]/(app)/admin/settings/page.tsx +++ b/app/[locale]/(app)/admin/settings/page.jsx @@ -9,11 +9,11 @@ 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 }>; -}) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function ClinicSettingsPage({ params }) { await params; // Next.js 16: params is async. const t = await getTranslations("admin.settings"); diff --git a/app/[locale]/(app)/admin/staff/actions.ts b/app/[locale]/(app)/admin/staff/actions.js similarity index 89% rename from app/[locale]/(app)/admin/staff/actions.ts rename to app/[locale]/(app)/admin/staff/actions.js index 92474bf..8cedb37 100644 --- a/app/[locale]/(app)/admin/staff/actions.ts +++ b/app/[locale]/(app)/admin/staff/actions.js @@ -22,7 +22,11 @@ async function revalidateStaff() { revalidatePath(`/${locale}/admin/staff`); } -export async function updateStaffRoleAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function updateStaffRoleAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; @@ -43,7 +47,11 @@ export async function updateStaffRoleAction(formData: FormData): Promise { } } -export async function removeStaffAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function removeStaffAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; diff --git a/app/[locale]/(app)/admin/staff/page.tsx b/app/[locale]/(app)/admin/staff/page.jsx similarity index 95% rename from app/[locale]/(app)/admin/staff/page.tsx rename to app/[locale]/(app)/admin/staff/page.jsx index 5baec94..6599eab 100644 --- a/app/[locale]/(app)/admin/staff/page.tsx +++ b/app/[locale]/(app)/admin/staff/page.jsx @@ -13,7 +13,11 @@ import { appRoles } from "@/lib/db/roles"; import { Button } from "@/components/ui/button"; import { updateStaffRoleAction, removeStaffAction } from "./actions"; -export default async function StaffPage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function StaffPage({ params }) { await params; const t = await getTranslations("admin.staff"); const tRoles = await getTranslations("roles"); diff --git a/app/[locale]/(app)/admin/templates/[id]/edit/page.tsx b/app/[locale]/(app)/admin/templates/[id]/edit/page.jsx similarity index 87% rename from app/[locale]/(app)/admin/templates/[id]/edit/page.tsx rename to app/[locale]/(app)/admin/templates/[id]/edit/page.jsx index a2a526b..db923f6 100644 --- a/app/[locale]/(app)/admin/templates/[id]/edit/page.tsx +++ b/app/[locale]/(app)/admin/templates/[id]/edit/page.jsx @@ -7,11 +7,11 @@ import { fieldsJsonToText } from "@/lib/templates/template-schema"; import { TemplateForm } from "../../template-form"; import { updateTemplateAction } from "../../actions"; -export default async function EditTemplatePage({ - params, -}: { - params: Promise<{ locale: string; id: string }>; -}) { +/** + * @param {{ params: Promise<{ locale: string, id: string }> }} props + * @returns {Promise} + */ +export default async function EditTemplatePage({ params }) { const { id } = await params; const t = await getTranslations("admin.templates"); const templateId = Number(id); diff --git a/app/[locale]/(app)/admin/templates/actions.ts b/app/[locale]/(app)/admin/templates/actions.js similarity index 74% rename from app/[locale]/(app)/admin/templates/actions.ts rename to app/[locale]/(app)/admin/templates/actions.js index 70fac41..da514ee 100644 --- a/app/[locale]/(app)/admin/templates/actions.ts +++ b/app/[locale]/(app)/admin/templates/actions.js @@ -12,15 +12,15 @@ import { revalidatePath } from "next/cache"; import { redirect } from "@/i18n/navigation"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; -import { - TemplateSchema, - fieldsTextToJson, - type TemplateFormState, - type TemplateInput, -} from "@/lib/templates/template-schema"; +import { TemplateSchema, fieldsTextToJson } from "@/lib/templates/template-schema"; -function readForm(formData: FormData) { - const get = (k: string) => String(formData.get(k) ?? ""); +/** @typedef {import('@/lib/templates/template-schema').TemplateFormState} TemplateFormState */ +/** @typedef {import('@/lib/templates/template-schema').TemplateInput} TemplateInput */ + +/** @param {FormData} formData */ +function readForm(formData) { + /** @param {string} k */ + const get = (k) => String(formData.get(k) ?? ""); return { name: get("name"), title: get("title"), @@ -30,7 +30,8 @@ function readForm(formData: FormData) { }; } -function toRow(d: TemplateInput) { +/** @param {TemplateInput} d */ +function toRow(d) { return { name: d.name, title: d.title.trim().length ? d.title.trim() : null, @@ -40,16 +41,19 @@ function toRow(d: TemplateInput) { }; } -async function finish(): Promise { +/** @returns {Promise} */ +async function finish() { const locale = await getLocale(); revalidatePath(`/${locale}/admin/templates`); return redirect({ href: `/${locale}/admin/templates`, locale }); } -export async function createTemplateAction( - _prev: TemplateFormState, - formData: FormData, -): Promise { +/** + * @param {TemplateFormState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function createTemplateAction(_prev, formData) { const t = await getTranslations("admin.templates"); const session = await getServerSession(); @@ -61,7 +65,7 @@ export async function createTemplateAction( if (!parsed.success) { return { status: "error", - fieldErrors: parsed.error.flatten().fieldErrors as Record, + fieldErrors: /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: null, }; } @@ -84,10 +88,12 @@ export async function createTemplateAction( return finish(); } -export async function updateTemplateAction( - _prev: TemplateFormState, - formData: FormData, -): Promise { +/** + * @param {TemplateFormState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function updateTemplateAction(_prev, formData) { const t = await getTranslations("admin.templates"); const session = await getServerSession(); @@ -102,7 +108,7 @@ export async function updateTemplateAction( status: "error", fieldErrors: parsed.success ? {} - : (parsed.error.flatten().fieldErrors as Record), + : /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: parsed.success ? t("errorGeneric") : null, }; } @@ -124,7 +130,11 @@ export async function updateTemplateAction( return finish(); } -export async function deactivateTemplateAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function deactivateTemplateAction(formData) { const session = await getServerSession(); if (session?.role !== "admin") return; diff --git a/app/[locale]/(app)/admin/templates/new/page.tsx b/app/[locale]/(app)/admin/templates/new/page.jsx similarity index 78% rename from app/[locale]/(app)/admin/templates/new/page.tsx rename to app/[locale]/(app)/admin/templates/new/page.jsx index 1cc5066..854bda4 100644 --- a/app/[locale]/(app)/admin/templates/new/page.tsx +++ b/app/[locale]/(app)/admin/templates/new/page.jsx @@ -4,7 +4,11 @@ import { getTranslations } from "next-intl/server"; import { TemplateForm } from "../template-form"; import { createTemplateAction } from "../actions"; -export default async function NewTemplatePage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function NewTemplatePage({ params }) { await params; const t = await getTranslations("admin.templates"); diff --git a/app/[locale]/(app)/admin/templates/page.tsx b/app/[locale]/(app)/admin/templates/page.jsx similarity index 83% rename from app/[locale]/(app)/admin/templates/page.tsx rename to app/[locale]/(app)/admin/templates/page.jsx index 3eb7346..7c5a862 100644 --- a/app/[locale]/(app)/admin/templates/page.tsx +++ b/app/[locale]/(app)/admin/templates/page.jsx @@ -10,7 +10,11 @@ import { createSupabaseServerClient } from "@/lib/supabase/server"; import { Button } from "@/components/ui/button"; import { deactivateTemplateAction } from "./actions"; -export default async function TemplatesPage({ params }: { params: Promise<{ locale: string }> }) { +/** + * @param {{ params: Promise<{ locale: string }> }} props + * @returns {Promise} + */ +export default async function TemplatesPage({ params }) { const { locale } = await params; const t = await getTranslations("admin.templates"); @@ -27,7 +31,7 @@ export default async function TemplatesPage({ params }: { params: Promise<{ loca

{t("title")}

@@ -45,7 +49,10 @@ export default async function TemplatesPage({ params }: { params: Promise<{ loca
diff --git a/app/[locale]/(app)/admin/templates/template-form.tsx b/app/[locale]/(app)/admin/templates/template-form.jsx similarity index 81% rename from app/[locale]/(app)/admin/templates/template-form.tsx rename to app/[locale]/(app)/admin/templates/template-form.jsx index f6ae6d2..73598a0 100644 --- a/app/[locale]/(app)/admin/templates/template-form.tsx +++ b/app/[locale]/(app)/admin/templates/template-form.jsx @@ -12,37 +12,40 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { templateGenders, type TemplateFormState } from "@/lib/templates/template-schema"; +import { templateGenders } from "@/lib/templates/template-schema"; -export type TemplateDefaults = { - id?: number; - name: string; - title: string; - gender: string; - photoNum: number; - fieldsText: string; -}; +/** @typedef {import('@/lib/templates/template-schema').TemplateFormState} TemplateFormState */ + +/** + * @typedef {Object} TemplateDefaults + * @property {number} [id] + * @property {string} name + * @property {string} title + * @property {string} gender + * @property {number} photoNum + * @property {string} fieldsText + */ const CONTROL = "border-input bg-background text-foreground focus-visible:ring-ring w-full rounded-md border px-3 text-sm focus:outline-none focus-visible:ring-2 disabled:opacity-50"; -export function TemplateForm({ - mode, - action, - defaults, -}: { - mode: "create" | "edit"; - action: (prev: TemplateFormState, formData: FormData) => Promise; - defaults: TemplateDefaults; -}) { +/** + * @param {{ + * mode: "create" | "edit", + * action: (prev: TemplateFormState, formData: FormData) => Promise, + * defaults: TemplateDefaults, + * }} props + */ +export function TemplateForm({ mode, action, defaults }) { const t = useTranslations("admin.templates"); - const [state, dispatch, isPending] = useActionState(action, { + const [state, dispatch, isPending] = useActionState(action, { status: "idle", }); const fe = state.status === "error" ? state.fieldErrors : {}; const formError = state.status === "error" ? state.formError : null; - const err = (f: string) => + /** @param {string} f */ + const err = (f) => fe[f]?.length ? (

{fe[f][0]} diff --git a/app/[locale]/(app)/checkups/[id]/imaging/actions.ts b/app/[locale]/(app)/checkups/[id]/imaging/actions.js similarity index 84% rename from app/[locale]/(app)/checkups/[id]/imaging/actions.ts rename to app/[locale]/(app)/checkups/[id]/imaging/actions.js index 948da1a..beb538a 100644 --- a/app/[locale]/(app)/checkups/[id]/imaging/actions.ts +++ b/app/[locale]/(app)/checkups/[id]/imaging/actions.js @@ -18,22 +18,27 @@ 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 { CHECKUP_MEDIA_BUCKET, DeleteImageSchema, RecordImageSchema, isValidStoragePath, - type ImageActionState, } from "@/lib/imaging/image-schema"; -const CLINICAL: AppRole[] = ["admin", "receptionist", "doctor", "nurse"]; -const isClinical = (r: AppRole | null | undefined) => !!r && CLINICAL.includes(r); +/** @typedef {import('@/lib/db/roles').AppRole} AppRole */ +/** @typedef {import('@/lib/imaging/image-schema').ImageActionState} ImageActionState */ -export async function recordImageAction( - checkupId: number, - storagePath: string, -): Promise { +/** @type {AppRole[]} */ +const CLINICAL = ["admin", "receptionist", "doctor", "nurse"]; +/** @param {AppRole | null | undefined} r */ +const isClinical = (r) => !!r && CLINICAL.includes(r); + +/** + * @param {number} checkupId + * @param {string} storagePath + * @returns {Promise} + */ +export async function recordImageAction(checkupId, storagePath) { const t = await getTranslations("imaging"); const session = await getServerSession(); @@ -65,11 +70,13 @@ export async function recordImageAction( return { status: "success" }; } -export async function deleteImageAction( - checkupId: number, - imageId: number, - storagePath: string, -): Promise { +/** + * @param {number} checkupId + * @param {number} imageId + * @param {string} storagePath + * @returns {Promise} + */ +export async function deleteImageAction(checkupId, imageId, storagePath) { const t = await getTranslations("imaging"); const session = await getServerSession(); diff --git a/app/[locale]/(app)/checkups/[id]/imaging/checkup-barcode.tsx b/app/[locale]/(app)/checkups/[id]/imaging/checkup-barcode.jsx similarity index 92% rename from app/[locale]/(app)/checkups/[id]/imaging/checkup-barcode.tsx rename to app/[locale]/(app)/checkups/[id]/imaging/checkup-barcode.jsx index e7cdb0e..7a0fc1f 100644 --- a/app/[locale]/(app)/checkups/[id]/imaging/checkup-barcode.tsx +++ b/app/[locale]/(app)/checkups/[id]/imaging/checkup-barcode.jsx @@ -14,9 +14,12 @@ import bwipjs from "bwip-js/browser"; import { Button } from "@/components/ui/button"; -export function CheckupBarcode({ checkupId }: { checkupId: number }) { +/** + * @param {{ checkupId: number }} props + */ +export function CheckupBarcode({ checkupId }) { const t = useTranslations("imaging"); - const canvasRef = useRef(null); + const canvasRef = useRef(/** @type {HTMLCanvasElement | null} */ (null)); const [error, setError] = useState(false); useEffect(() => { diff --git a/app/[locale]/(app)/checkups/[id]/imaging/image-capture.tsx b/app/[locale]/(app)/checkups/[id]/imaging/image-capture.jsx similarity index 86% rename from app/[locale]/(app)/checkups/[id]/imaging/image-capture.tsx rename to app/[locale]/(app)/checkups/[id]/imaging/image-capture.jsx index 0478d4c..04b96ec 100644 --- a/app/[locale]/(app)/checkups/[id]/imaging/image-capture.tsx +++ b/app/[locale]/(app)/checkups/[id]/imaging/image-capture.jsx @@ -28,18 +28,21 @@ import { } from "@/lib/imaging/image-schema"; import { recordImageAction } from "./actions"; -const QUALITY_STEPS = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4] as const; +/** @type {readonly [0.9, 0.8, 0.7, 0.6, 0.5, 0.4]} */ +const QUALITY_STEPS = [0.9, 0.8, 0.7, 0.6, 0.5, 0.4]; // Downscale the longest side to this before quality-stepping — a full-res phone // photo (e.g. 4000×3000) never fits 200KB on JPEG quality alone, so we must // reduce resolution first. 1280px keeps ultrasound/clinic detail legible. const MAX_DIMENSION = 1280; -/** Draw a source (video frame or image) onto a canvas scaled to fit MAX_DIMENSION. */ -function makeScaledCanvas( - source: CanvasImageSource, - w: number, - h: number, -): HTMLCanvasElement | null { +/** + * Draw a source (video frame or image) onto a canvas scaled to fit MAX_DIMENSION. + * @param {CanvasImageSource} source + * @param {number} w + * @param {number} h + * @returns {HTMLCanvasElement | null} + */ +function makeScaledCanvas(source, w, h) { const scale = Math.min(1, MAX_DIMENSION / Math.max(w, h)); const canvas = document.createElement("canvas"); canvas.width = Math.max(1, Math.round(w * scale)); @@ -58,13 +61,23 @@ const getCameraSupportSnapshot = () => typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia; const getCameraSupportServerSnapshot = () => false; -function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise { +/** + * @param {HTMLCanvasElement} canvas + * @param {number} quality + * @returns {Promise} + */ +function canvasToBlob(canvas, quality) { return new Promise((resolve) => canvas.toBlob((b) => resolve(b), "image/jpeg", quality)); } -/** Steps JPEG quality down from 0.9 to 0.4 until the blob fits MAX_IMAGE_BYTES. */ -async function compressToJpeg(canvas: HTMLCanvasElement): Promise { - let smallest: Blob | null = null; +/** + * Steps JPEG quality down from 0.9 to 0.4 until the blob fits MAX_IMAGE_BYTES. + * @param {HTMLCanvasElement} canvas + * @returns {Promise} + */ +async function compressToJpeg(canvas) { + /** @type {Blob | null} */ + let smallest = null; for (const q of QUALITY_STEPS) { const blob = await canvasToBlob(canvas, q); if (!blob) continue; @@ -74,12 +87,15 @@ async function compressToJpeg(canvas: HTMLCanvasElement): Promise { return smallest && smallest.size <= MAX_IMAGE_BYTES ? smallest : null; } -export function ImageCapture({ checkupId }: { checkupId: number }) { +/** + * @param {{ checkupId: number }} props + */ +export function ImageCapture({ checkupId }) { const t = useTranslations("imaging"); const router = useRouter(); - const videoRef = useRef(null); - const streamRef = useRef(null); + const videoRef = useRef(/** @type {HTMLVideoElement | null} */ (null)); + const streamRef = useRef(/** @type {MediaStream | null} */ (null)); // Browser-only feature detection without an Effect: getServerSnapshot // returns false so SSR/hydration render the same (no-camera) markup, then @@ -91,7 +107,7 @@ export function ImageCapture({ checkupId }: { checkupId: number }) { ); const [streaming, setStreaming] = useState(false); const [uploading, setUploading] = useState(false); - const [error, setError] = useState(null); + const [error, setError] = useState(/** @type {string | null} */ (null)); // Stop any open camera stream on unmount. useEffect(() => { @@ -122,7 +138,8 @@ export function ImageCapture({ checkupId }: { checkupId: number }) { setStreaming(false); } - async function uploadFromCanvas(canvas: HTMLCanvasElement) { + /** @param {HTMLCanvasElement} canvas */ + async function uploadFromCanvas(canvas) { setUploading(true); setError(null); try { @@ -169,7 +186,8 @@ export function ImageCapture({ checkupId }: { checkupId: number }) { void uploadFromCanvas(canvas); } - function handleFileChange(e: React.ChangeEvent) { + /** @param {import("react").ChangeEvent} e */ + function handleFileChange(e) { const file = e.target.files?.[0]; e.target.value = ""; // allow re-selecting the same file if (!file) return; diff --git a/app/[locale]/(app)/checkups/[id]/imaging/image-gallery.tsx b/app/[locale]/(app)/checkups/[id]/imaging/image-gallery.jsx similarity index 84% rename from app/[locale]/(app)/checkups/[id]/imaging/image-gallery.tsx rename to app/[locale]/(app)/checkups/[id]/imaging/image-gallery.jsx index 39503f0..d1ec714 100644 --- a/app/[locale]/(app)/checkups/[id]/imaging/image-gallery.tsx +++ b/app/[locale]/(app)/checkups/[id]/imaging/image-gallery.jsx @@ -15,16 +15,20 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { deleteImageAction } from "./actions"; -type GalleryImage = { id: number; storagePath: string; url: string | null }; +/** @typedef {{ id: number, storagePath: string, url: string | null }} GalleryImage */ -export function ImageGallery({ checkupId, images }: { checkupId: number; images: GalleryImage[] }) { +/** + * @param {{ checkupId: number, images: GalleryImage[] }} props + */ +export function ImageGallery({ checkupId, images }) { const t = useTranslations("imaging"); const router = useRouter(); const [isPending, startTransition] = useTransition(); - const [pendingId, setPendingId] = useState(null); - const [error, setError] = useState(null); + const [pendingId, setPendingId] = useState(/** @type {number | null} */ (null)); + const [error, setError] = useState(/** @type {string | null} */ (null)); - function handleDelete(image: GalleryImage) { + /** @param {GalleryImage} image */ + function handleDelete(image) { setError(null); setPendingId(image.id); startTransition(async () => { diff --git a/app/[locale]/(app)/checkups/[id]/imaging/page.tsx b/app/[locale]/(app)/checkups/[id]/imaging/page.jsx similarity index 94% rename from app/[locale]/(app)/checkups/[id]/imaging/page.tsx rename to app/[locale]/(app)/checkups/[id]/imaging/page.jsx index 4a37792..fd18756 100644 --- a/app/[locale]/(app)/checkups/[id]/imaging/page.tsx +++ b/app/[locale]/(app)/checkups/[id]/imaging/page.jsx @@ -18,11 +18,11 @@ import { ImageCapture } from "./image-capture"; import { ImageGallery } from "./image-gallery"; import { CheckupBarcode } from "./checkup-barcode"; -export default async function ImagingPage({ - params, -}: { - params: Promise<{ locale: string; id: string }>; -}) { +/** + * @param {{ params: Promise<{ locale: string, id: string }> }} props + * @returns {Promise} + */ +export default async function ImagingPage({ params }) { const { locale, id } = await params; const t = await getTranslations("imaging"); const tReports = await getTranslations("reports"); diff --git a/app/[locale]/(app)/checkups/[id]/invoice/route.ts b/app/[locale]/(app)/checkups/[id]/invoice/route.js similarity index 89% rename from app/[locale]/(app)/checkups/[id]/invoice/route.ts rename to app/[locale]/(app)/checkups/[id]/invoice/route.js index ff9343f..61f0ee1 100644 --- a/app/[locale]/(app)/checkups/[id]/invoice/route.ts +++ b/app/[locale]/(app)/checkups/[id]/invoice/route.js @@ -7,16 +7,19 @@ import { getTranslations } from "next-intl/server"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; -import { renderInvoicePdf, type InvoiceLine } from "@/lib/pdf/invoice-document"; +import { renderInvoicePdf } from "@/lib/pdf/invoice-document"; import { sumLineTotals } from "@/lib/billing/totals"; +/** @typedef {import('@/lib/pdf/invoice-document').InvoiceLine} InvoiceLine */ + export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function GET( - _req: Request, - { params }: { params: Promise<{ locale: string; id: string }> }, -) { +/** + * @param {Request} _req + * @param {{ params: Promise<{ locale: string, id: string }> }} context + */ +export async function GET(_req, { params }) { const { id } = await params; const checkupId = Number(id); if (!Number.isFinite(checkupId)) return new Response("Not found", { status: 404 }); @@ -69,13 +72,15 @@ export async function GET( const medName = new Map((meds ?? []).map((m) => [m.id, m.name])); const svcName = new Map((services ?? []).map((x) => [x.id, x.name])); - const medicines: InvoiceLine[] = (items ?? []).map((i) => ({ + /** @type {InvoiceLine[]} */ + const medicines = (items ?? []).map((i) => ({ name: medName.get(i.medicine_id) ?? "—", quantity: i.quantity, unitPrice: i.unit_price, lineTotal: i.line_total, })); - const serviceLines: InvoiceLine[] = (svcs ?? []).map((x) => ({ + /** @type {InvoiceLine[]} */ + const serviceLines = (svcs ?? []).map((x) => ({ name: svcName.get(x.service_id) ?? "—", quantity: x.quantity, unitPrice: x.unit_price, diff --git a/app/[locale]/(app)/checkups/[id]/page.tsx b/app/[locale]/(app)/checkups/[id]/page.jsx similarity index 84% rename from app/[locale]/(app)/checkups/[id]/page.tsx rename to app/[locale]/(app)/checkups/[id]/page.jsx index 9e2659b..61344b2 100644 --- a/app/[locale]/(app)/checkups/[id]/page.tsx +++ b/app/[locale]/(app)/checkups/[id]/page.jsx @@ -9,13 +9,14 @@ import { Button } from "@/components/ui/button"; import { CheckupForm } from "../checkup-form"; import { DeleteCheckupButton } from "../delete-checkup-button"; -const str = (v: string | number | null) => (v == null ? "" : String(v)); +/** @param {string | number | null} v */ +const str = (v) => (v == null ? "" : String(v)); -export default async function CheckupPage({ - params, -}: { - params: Promise<{ locale: string; id: string }>; -}) { +/** + * @param {{ params: Promise<{ locale: string, id: string }> }} props + * @returns {Promise} + */ +export default async function CheckupPage({ params }) { const { locale, id } = await params; const t = await getTranslations("checkups"); const tBilling = await getTranslations("billing"); @@ -58,7 +59,9 @@ export default async function CheckupPage({ // De-dupe + trim recent diagnoses for the quick-pick, capped at 50 entries. const diagnosisSuggestions = [ ...new Set( - (recentDiagnoses ?? []).map((r) => r.diagnosis?.trim()).filter((d): d is string => !!d), + (recentDiagnoses ?? []) + .map((r) => r.diagnosis?.trim()) + .filter(/** @returns {d is string} */ (d) => !!d), ), ].slice(0, 50); @@ -71,8 +74,11 @@ export default async function CheckupPage({ : (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") + ? /** @type {unknown[]} */ (c.template_values) + .filter( + /** @returns {v is { label: unknown, value: unknown }} */ + (v) => !!v && typeof v === "object", + ) .map((v) => ({ label: String(v.label ?? ""), value: String(v.value ?? "") })) : []; @@ -102,12 +108,15 @@ export default async function CheckupPage({

diff --git a/app/[locale]/(app)/checkups/[id]/prescription/actions.ts b/app/[locale]/(app)/checkups/[id]/prescription/actions.js similarity index 76% rename from app/[locale]/(app)/checkups/[id]/prescription/actions.ts rename to app/[locale]/(app)/checkups/[id]/prescription/actions.js index 7e74f36..0a808ad 100644 --- a/app/[locale]/(app)/checkups/[id]/prescription/actions.ts +++ b/app/[locale]/(app)/checkups/[id]/prescription/actions.js @@ -16,35 +16,47 @@ import { revalidatePath } from "next/cache"; 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 { MedicineLinesSchema, ServiceLinesSchema, MarkPaidSchema, - type PrescriptionSaveState, - type MarkPaidState, } from "@/lib/billing/prescription-schema"; -const CLINICAL: AppRole[] = ["admin", "receptionist", "doctor", "nurse"]; -const BILLING: AppRole[] = ["admin", "cashier"]; -const isClinical = (r: AppRole | null | undefined) => !!r && CLINICAL.includes(r); -const isBilling = (r: AppRole | null | undefined) => !!r && BILLING.includes(r); +/** @typedef {import('@/lib/db/roles').AppRole} AppRole */ +/** @typedef {import('@/lib/billing/prescription-schema').PrescriptionSaveState} PrescriptionSaveState */ +/** @typedef {import('@/lib/billing/prescription-schema').MarkPaidState} MarkPaidState */ -/** Parses a JSON-array form field; returns [] on any malformed input. */ -function parseJsonArray(raw: FormDataEntryValue | null): unknown[] { +/** @type {AppRole[]} */ +const CLINICAL = ["admin", "receptionist", "doctor", "nurse"]; +/** @type {AppRole[]} */ +const BILLING = ["admin", "cashier"]; +/** @param {AppRole | null | undefined} r */ +const isClinical = (r) => !!r && CLINICAL.includes(r); +/** @param {AppRole | null | undefined} r */ +const isBilling = (r) => !!r && BILLING.includes(r); + +/** + * Parses a JSON-array form field; returns [] on any malformed input. + * @param {FormDataEntryValue | null} raw + * @returns {unknown[]} + */ +function parseJsonArray(raw) { if (typeof raw !== "string") return []; try { - const v: unknown = JSON.parse(raw); + /** @type {unknown} */ + const v = JSON.parse(raw); return Array.isArray(v) ? v : []; } catch { return []; } } -export async function savePrescriptionAction( - _prev: PrescriptionSaveState, - formData: FormData, -): Promise { +/** + * @param {PrescriptionSaveState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function savePrescriptionAction(_prev, formData) { const t = await getTranslations("billing"); const session = await getServerSession(); @@ -95,10 +107,12 @@ export async function savePrescriptionAction( return redirect({ href: `/${locale}/checkups/${checkupId}`, locale }); } -export async function markPaidAction( - _prev: MarkPaidState, - formData: FormData, -): Promise { +/** + * @param {MarkPaidState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function markPaidAction(_prev, formData) { const t = await getTranslations("billing"); const session = await getServerSession(); diff --git a/app/[locale]/(app)/checkups/[id]/prescription/page.tsx b/app/[locale]/(app)/checkups/[id]/prescription/page.jsx similarity index 95% rename from app/[locale]/(app)/checkups/[id]/prescription/page.tsx rename to app/[locale]/(app)/checkups/[id]/prescription/page.jsx index 45f40e5..8bde153 100644 --- a/app/[locale]/(app)/checkups/[id]/prescription/page.tsx +++ b/app/[locale]/(app)/checkups/[id]/prescription/page.jsx @@ -16,11 +16,11 @@ import { Button } from "@/components/ui/button"; import { routing } from "@/i18n/routing"; import { PrescriptionComposer } from "./prescription-composer"; -export default async function PrescriptionPage({ - params, -}: { - params: Promise<{ locale: string; id: string }>; -}) { +/** + * @param {{ params: Promise<{ locale: string, id: string }> }} props + * @returns {Promise} + */ +export default async function PrescriptionPage({ params }) { const { locale, id } = await params; const t = await getTranslations("billing"); const tReports = await getTranslations("reports"); diff --git a/app/[locale]/(app)/checkups/[id]/prescription/pdf/route.ts b/app/[locale]/(app)/checkups/[id]/prescription/pdf/route.js similarity index 90% rename from app/[locale]/(app)/checkups/[id]/prescription/pdf/route.ts rename to app/[locale]/(app)/checkups/[id]/prescription/pdf/route.js index 48ebac5..649aa5f 100644 --- a/app/[locale]/(app)/checkups/[id]/prescription/pdf/route.ts +++ b/app/[locale]/(app)/checkups/[id]/prescription/pdf/route.js @@ -9,18 +9,18 @@ import { getTranslations } from "next-intl/server"; import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { computeAge } from "@/lib/pdf/patient-info"; -import { - renderPrescriptionPdf, - type PrescriptionMedicineLine, -} from "@/lib/pdf/prescription-document"; +import { renderPrescriptionPdf } from "@/lib/pdf/prescription-document"; + +/** @typedef {import('@/lib/pdf/prescription-document').PrescriptionMedicineLine} PrescriptionMedicineLine */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -export async function GET( - _req: Request, - { params }: { params: Promise<{ locale: string; id: string }> }, -) { +/** + * @param {Request} _req + * @param {{ params: Promise<{ locale: string, id: string }> }} context + */ +export async function GET(_req, { params }) { const { id } = await params; const checkupId = Number(id); if (!Number.isFinite(checkupId)) return new Response("Not found", { status: 404 }); @@ -71,7 +71,8 @@ export async function GET( : { data: [] }; const medById = new Map((meds ?? []).map((m) => [m.id, m])); - const medicines: PrescriptionMedicineLine[] = (items ?? []).map((i) => { + /** @type {PrescriptionMedicineLine[]} */ + const medicines = (items ?? []).map((i) => { const med = medById.get(i.medicine_id); return { name: med?.name ?? "—", diff --git a/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx b/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.jsx similarity index 81% rename from app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx rename to app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.jsx index c1a7a71..1a9e68f 100644 --- a/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.tsx +++ b/app/[locale]/(app)/checkups/[id]/prescription/prescription-composer.jsx @@ -18,38 +18,51 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { dosePresets } from "@/lib/billing/dose-presets"; -import { - paymentMethods, - type MarkPaidState, - type PrescriptionSaveState, -} from "@/lib/billing/prescription-schema"; +import { paymentMethods } from "@/lib/billing/prescription-schema"; import { markPaidAction, savePrescriptionAction } from "./actions"; +/** @typedef {import('@/lib/billing/prescription-schema').MarkPaidState} MarkPaidState */ +/** @typedef {import('@/lib/billing/prescription-schema').PrescriptionSaveState} PrescriptionSaveState */ + const DOSE_PRESETS_LIST_ID = "dose-presets"; -const vnd = (n: number) => `${new Intl.NumberFormat("vi-VN").format(n)} ₫`; +/** @param {number} n */ +const vnd = (n) => `${new Intl.NumberFormat("vi-VN").format(n)} ₫`; const SELECT = "border-input bg-background text-foreground focus-visible:ring-ring h-10 w-full rounded-md border px-3 text-sm focus:outline-none focus-visible:ring-2 disabled:opacity-50"; -type Medicine = { id: number; name: string; unit: string | null; sale_price: number }; -type Service = { id: number; name: string; price: number }; +/** @typedef {{ id: number, name: string, unit: string | null, sale_price: number }} Medicine */ +/** @typedef {{ id: number, name: string, price: number }} Service */ -type MedicineRow = { - key: string; - medicineId: number; - quantity: number; - dosage: string; - notes: string; -}; -type ServiceRow = { key: string; serviceId: number; quantity: number }; +/** + * @typedef {object} MedicineRow + * @property {string} key + * @property {number} medicineId + * @property {number} quantity + * @property {string} dosage + * @property {string} notes + */ +/** @typedef {{ key: string, serviceId: number, quantity: number }} ServiceRow */ -type Payment = { - status: "unpaid" | "paid"; - method: string | null; - paidAt: string | null; -}; +/** + * @typedef {object} Payment + * @property {"unpaid" | "paid"} status + * @property {string | null} method + * @property {string | null} paidAt + */ +/** + * @param {{ + * checkupId: number, + * medicines: Medicine[], + * services: Service[], + * initialMedicineLines: { medicineId: number, quantity: number, dosage: string, notes: string }[], + * initialServiceLines: { serviceId: number, quantity: number }[], + * payment: Payment, + * canMarkPaid: boolean, + * }} props + */ export function PrescriptionComposer({ checkupId, medicines, @@ -58,34 +71,24 @@ export function PrescriptionComposer({ initialServiceLines, payment, canMarkPaid, -}: { - checkupId: number; - medicines: Medicine[]; - services: Service[]; - initialMedicineLines: { medicineId: number; quantity: number; dosage: string; notes: string }[]; - initialServiceLines: { serviceId: number; quantity: number }[]; - payment: Payment; - canMarkPaid: boolean; }) { const t = useTranslations("billing"); - const [medicineRows, setMedicineRows] = useState(() => - initialMedicineLines.map((l, i) => ({ ...l, key: `m-init-${i}` })), + const [medicineRows, setMedicineRows] = useState( + /** @type {MedicineRow[]} */ ( + initialMedicineLines.map((l, i) => ({ ...l, key: `m-init-${i}` })) + ), ); - const [serviceRows, setServiceRows] = useState(() => - initialServiceLines.map((l, i) => ({ ...l, key: `s-init-${i}` })), + const [serviceRows, setServiceRows] = useState( + /** @type {ServiceRow[]} */ (initialServiceLines.map((l, i) => ({ ...l, key: `s-init-${i}` }))), ); - const [saveState, saveDispatch, isSaving] = useActionState( - savePrescriptionAction, - { status: "idle" }, - ); - const [payState, payDispatch, isPaying] = useActionState( - markPaidAction, - { - status: "idle", - }, - ); + const [saveState, saveDispatch, isSaving] = useActionState(savePrescriptionAction, { + status: "idle", + }); + const [payState, payDispatch, isPaying] = useActionState(markPaidAction, { + status: "idle", + }); const medMap = new Map(medicines.map((m) => [m.id, m.sale_price])); const svcMap = new Map(services.map((s) => [s.id, s.price])); @@ -101,26 +104,32 @@ export function PrescriptionComposer({ notes: "", }, ]); - const removeMedicineRow = (key: string) => - setMedicineRows((rows) => rows.filter((r) => r.key !== key)); - const updateMedicineRow = ( - key: string, - field: K, - value: MedicineRow[K], - ) => setMedicineRows((rows) => rows.map((r) => (r.key === key ? { ...r, [field]: value } : r))); + /** @param {string} key */ + const removeMedicineRow = (key) => setMedicineRows((rows) => rows.filter((r) => r.key !== key)); + /** + * @template {keyof MedicineRow} K + * @param {string} key + * @param {K} field + * @param {MedicineRow[K]} value + */ + const updateMedicineRow = (key, field, value) => + setMedicineRows((rows) => rows.map((r) => (r.key === key ? { ...r, [field]: value } : r))); const addServiceRow = () => setServiceRows((rows) => [ ...rows, { key: `s-${crypto.randomUUID()}`, serviceId: services[0]?.id ?? 0, quantity: 1 }, ]); - const removeServiceRow = (key: string) => - setServiceRows((rows) => rows.filter((r) => r.key !== key)); - const updateServiceRow = ( - key: string, - field: K, - value: ServiceRow[K], - ) => setServiceRows((rows) => rows.map((r) => (r.key === key ? { ...r, [field]: value } : r))); + /** @param {string} key */ + const removeServiceRow = (key) => setServiceRows((rows) => rows.filter((r) => r.key !== key)); + /** + * @template {keyof ServiceRow} K + * @param {string} key + * @param {K} field + * @param {ServiceRow[K]} value + */ + const updateServiceRow = (key, field, value) => + setServiceRows((rows) => rows.map((r) => (r.key === key ? { ...r, [field]: value } : r))); const medicineTotal = medicineRows.reduce( (sum, r) => sum + (medMap.get(r.medicineId) ?? 0) * (r.quantity || 0), diff --git a/app/[locale]/(app)/checkups/[id]/report/route.ts b/app/[locale]/(app)/checkups/[id]/report/route.js similarity index 78% rename from app/[locale]/(app)/checkups/[id]/report/route.ts rename to app/[locale]/(app)/checkups/[id]/report/route.js index f6dcc6a..3d944b8 100644 --- a/app/[locale]/(app)/checkups/[id]/report/route.ts +++ b/app/[locale]/(app)/checkups/[id]/report/route.js @@ -14,17 +14,20 @@ import { getServerSession } from "@/lib/auth/get-server-session"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { CHECKUP_MEDIA_BUCKET, SIGNED_URL_TTL_SECONDS } from "@/lib/imaging/image-schema"; import { computeAge } from "@/lib/pdf/patient-info"; -import { renderUltrasoundPdf, type UltrasoundImage } from "@/lib/pdf/ultrasound-document"; +import { renderUltrasoundPdf } from "@/lib/pdf/ultrasound-document"; + +/** @typedef {import('@/lib/pdf/ultrasound-document').UltrasoundImage} UltrasoundImage */ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const MAX_REPORT_IMAGES = 4; -export async function GET( - _req: Request, - { params }: { params: Promise<{ locale: string; id: string }> }, -) { +/** + * @param {Request} _req + * @param {{ params: Promise<{ locale: string, id: string }> }} context + */ +export async function GET(_req, { params }) { const { id } = await params; const checkupId = Number(id); if (!Number.isFinite(checkupId)) return new Response("Not found", { status: 404 }); @@ -75,23 +78,27 @@ export async function GET( const tPatients = await getTranslations("patients"); const downloaded = await Promise.all( - (images ?? []).map(async (img): Promise => { - const { data: signed } = await supabase.storage - .from(CHECKUP_MEDIA_BUCKET) - .createSignedUrl(img.storage_path, SIGNED_URL_TTL_SECONDS); - if (!signed?.signedUrl) return null; - try { - const res = await fetch(signed.signedUrl); - if (!res.ok) return null; - const arrayBuffer = await res.arrayBuffer(); - return { data: Buffer.from(arrayBuffer), format: "jpg" }; - } catch { - return null; - } - }), + (images ?? []).map( + /** @returns {Promise} */ + async (img) => { + const { data: signed } = await supabase.storage + .from(CHECKUP_MEDIA_BUCKET) + .createSignedUrl(img.storage_path, SIGNED_URL_TTL_SECONDS); + if (!signed?.signedUrl) return null; + try { + const res = await fetch(signed.signedUrl); + if (!res.ok) return null; + const arrayBuffer = await res.arrayBuffer(); + return { data: Buffer.from(arrayBuffer), format: "jpg" }; + } catch { + return null; + } + }, + ), ); - const reportImages: UltrasoundImage[] = downloaded.filter( - (img): img is UltrasoundImage => img != null, + /** @type {UltrasoundImage[]} */ + const reportImages = downloaded.filter( + /** @returns {img is UltrasoundImage} */ (img) => img != null, ); const buffer = await renderUltrasoundPdf({ diff --git a/app/[locale]/(app)/checkups/actions.ts b/app/[locale]/(app)/checkups/actions.js similarity index 77% rename from app/[locale]/(app)/checkups/actions.ts rename to app/[locale]/(app)/checkups/actions.js index 6cd6def..b345c1b 100644 --- a/app/[locale]/(app)/checkups/actions.ts +++ b/app/[locale]/(app)/checkups/actions.js @@ -12,22 +12,24 @@ import { revalidatePath } from "next/cache"; 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, - parseTemplateValues, - type CheckupSaveState, -} from "@/lib/checkups/checkup-schema"; +import { CheckupSaveSchema, parseNum, parseTemplateValues } from "@/lib/checkups/checkup-schema"; -const CLINICAL: AppRole[] = ["admin", "receptionist", "doctor", "nurse"]; -const isClinical = (r: AppRole | null | undefined) => !!r && CLINICAL.includes(r); -const nullIfBlank = (s: string) => (s.trim().length ? s.trim() : null); +/** @typedef {import('@/lib/db/roles').AppRole} AppRole */ +/** @typedef {import('@/lib/checkups/checkup-schema').CheckupSaveState} CheckupSaveState */ -export async function saveCheckupAction( - _prev: CheckupSaveState, - formData: FormData, -): Promise { +/** @type {AppRole[]} */ +const CLINICAL = ["admin", "receptionist", "doctor", "nurse"]; +/** @param {AppRole | null | undefined} r */ +const isClinical = (r) => !!r && CLINICAL.includes(r); +/** @param {string} s */ +const nullIfBlank = (s) => (s.trim().length ? s.trim() : null); + +/** + * @param {CheckupSaveState} _prev + * @param {FormData} formData + * @returns {Promise} + */ +export async function saveCheckupAction(_prev, formData) { const t = await getTranslations("checkups"); const session = await getServerSession(); @@ -36,7 +38,8 @@ export async function saveCheckupAction( } const id = Number(formData.get("id")); - const get = (k: string) => String(formData.get(k) ?? ""); + /** @param {string} k */ + const get = (k) => String(formData.get(k) ?? ""); const parsed = CheckupSaveSchema.safeParse({ heartBeat: get("heartBeat"), bloodPressure: get("bloodPressure"), @@ -56,7 +59,7 @@ export async function saveCheckupAction( status: "error", fieldErrors: parsed.success ? {} - : (parsed.error.flatten().fieldErrors as Record), + : /** @type {Record} */ (parsed.error.flatten().fieldErrors), formError: parsed.success ? t("errorGeneric") : null, }; } @@ -104,7 +107,11 @@ export async function saveCheckupAction( } // ── Soft-delete ─────────────────────────────────────────────────────────────── -export async function deleteCheckupAction(formData: FormData): Promise { +/** + * @param {FormData} formData + * @returns {Promise} + */ +export async function deleteCheckupAction(formData) { const session = await getServerSession(); if (!isClinical(session?.role)) return; diff --git a/app/[locale]/(app)/checkups/checkup-form.tsx b/app/[locale]/(app)/checkups/checkup-form.jsx similarity index 81% rename from app/[locale]/(app)/checkups/checkup-form.tsx rename to app/[locale]/(app)/checkups/checkup-form.jsx index 770ce98..36c119b 100644 --- a/app/[locale]/(app)/checkups/checkup-form.tsx +++ b/app/[locale]/(app)/checkups/checkup-form.jsx @@ -6,56 +6,59 @@ * (redirects to the queue on success). */ -import { useActionState, useEffect, useRef, useState, type RefObject } from "react"; +import { useActionState, useEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { checkupStatuses, type CheckupSaveState } from "@/lib/checkups/checkup-schema"; +import { checkupStatuses } from "@/lib/checkups/checkup-schema"; import { saveCheckupAction } from "./actions"; +/** @typedef {import('@/lib/checkups/checkup-schema').CheckupSaveState} CheckupSaveState */ + const CONTROL = "border-input bg-background text-foreground focus-visible:ring-ring w-full rounded-md border px-3 text-sm focus:outline-none focus-visible:ring-2 disabled:opacity-50"; -export type CheckupDefaults = { - id: number; - heartBeat: string; - bloodPressure: string; - temperature: string; - weight: string; - height: string; - symptoms: string; - diagnosis: string; - conclusion: string; - notes: string; - recheckDate: string; - status: string; -}; +/** + * @typedef {object} CheckupDefaults + * @property {number} id + * @property {string} heartBeat + * @property {string} bloodPressure + * @property {string} temperature + * @property {string} weight + * @property {string} height + * @property {string} symptoms + * @property {string} diagnosis + * @property {string} conclusion + * @property {string} notes + * @property {string} recheckDate + * @property {string} status + */ -export type CheckupTemplateOption = { id: number; name: string; labels: string[] }; -type TemplateValue = { label: string; value: string }; +/** @typedef {{ id: number, name: string, labels: string[] }} CheckupTemplateOption */ +/** @typedef {{ label: string, value: string }} TemplateValue */ +/** + * @param {{ + * defaults: CheckupDefaults, + * templates: CheckupTemplateOption[], + * initialTemplateId: number | null, + * initialTemplateValues: TemplateValue[], + * diagnosisSuggestions?: string[], + * }} props + */ export function CheckupForm({ defaults, templates, initialTemplateId, initialTemplateValues, diagnosisSuggestions = [], -}: { - defaults: CheckupDefaults; - templates: CheckupTemplateOption[]; - initialTemplateId: number | null; - initialTemplateValues: TemplateValue[]; - diagnosisSuggestions?: string[]; }) { const t = useTranslations("checkups"); - const [state, dispatch, isPending] = useActionState( - saveCheckupAction, - { - status: "idle", - }, - ); + const [state, dispatch, isPending] = useActionState(saveCheckupAction, { + status: "idle", + }); const formError = state.status === "error" ? state.formError : null; // Unsaved-changes guard: any edit flips `dirty`; saving clears it so a @@ -63,11 +66,12 @@ export function CheckupForm({ // beforeunload doesn't fire for client-side route changes, so we also show // an in-app marker near the Save button. const [dirty, setDirty] = useState(false); - const diagnosisRef = useRef(null); + const diagnosisRef = useRef(/** @type {HTMLTextAreaElement | null} */ (null)); useEffect(() => { if (!dirty || isPending) return; - function onBeforeUnload(e: BeforeUnloadEvent) { + /** @param {BeforeUnloadEvent} e */ + function onBeforeUnload(e) { e.preventDefault(); e.returnValue = ""; } @@ -80,7 +84,8 @@ export function CheckupForm({ // appends the chosen text into the (uncontrolled) textarea via ref — the // least disruptive option since it doesn't require converting the field to // controlled state. - function applyDiagnosisSuggestion(text: string) { + /** @param {string} text */ + function applyDiagnosisSuggestion(text) { if (!text) return; const el = diagnosisRef.current; if (!el) return; @@ -95,8 +100,10 @@ export function CheckupForm({ const [templateId, setTemplateId] = useState( initialTemplateId != null ? String(initialTemplateId) : "", ); - const [templateFieldValues, setTemplateFieldValues] = useState>(() => - Object.fromEntries(initialTemplateValues.map((v) => [v.label, v.value])), + const [templateFieldValues, setTemplateFieldValues] = useState( + /** @type {Record} */ ( + Object.fromEntries(initialTemplateValues.map((v) => [v.label, v.value])) + ), ); const selectedTemplate = templates.find((tpl) => String(tpl.id) === templateId) ?? null; @@ -107,18 +114,23 @@ export function CheckupForm({ })), ); - const textField = (name: keyof CheckupDefaults, label: string) => ( + /** + * @param {keyof CheckupDefaults} name + * @param {string} label + */ + const textField = (name, label) => (
); - const area = ( - name: keyof CheckupDefaults, - label: string, - ref?: RefObject, - ) => ( + /** + * @param {keyof CheckupDefaults} name + * @param {string} label + * @param {import("react").RefObject} [ref] + */ + const area = (name, label, ref) => (