Merge pull request #4 from tiennm99/ts-to-js-migration

refactor: migrate from TypeScript to JavaScript with JSDoc types
This commit is contained in:
2026-08-18 08:46:40 +07:00
committed by GitHub
152 changed files with 1946 additions and 1737 deletions
+3 -3
View File
@@ -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
@@ -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<DoctorFormState> {
/**
* @param {DoctorFormState} _prev
* @param {FormData} formData
* @returns {Promise<DoctorFormState>}
*/
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<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (parsed.error.flatten().fieldErrors),
formError: null,
};
}
@@ -70,7 +74,11 @@ export async function createDoctorAction(
}
// ── Update ───────────────────────────────────────────────────────────────────
export async function updateDoctorAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
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<void> {
}
// ── Soft-delete (deactivate) ─────────────────────────────────────────────────
export async function deactivateDoctorAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function deactivateDoctorAction(formData) {
const session = await getServerSession();
if (session?.role !== "admin") return;
@@ -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<DoctorFormState, FormData>(
createDoctorAction,
{ status: "idle" },
);
const [state, dispatchAction, isPending] = useActionState(createDoctorAction, {
status: "idle",
});
const form = useForm<DoctorInput>({
const form = useForm({
resolver: zodResolver(DoctorSchema),
mode: "onBlur",
defaultValues: { firstName: "", lastName: "" },
@@ -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<import("react").JSX.Element>}
*/
export default async function DoctorsPage({ params }) {
await params; // Next.js 16: params is async.
const t = await getTranslations("admin.doctors");
@@ -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<InviteUserState> {
/**
* @param {InviteUserState} _prevState
* @param {FormData} formData
* @returns {Promise<InviteUserState>}
*/
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<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (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" ||
@@ -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<InviteUserState, FormData>(
inviteUserAction,
{ status: "idle" },
);
const [state, dispatchAction, isPending] = useActionState(inviteUserAction, {
status: "idle",
});
const form = useForm<InviteUserInput>({
const form = useForm({
resolver: zodResolver(InviteUserSchema),
mode: "onBlur",
defaultValues: { email: "", role: "patient" },
defaultValues: { email: "", role: /** @type {InviteUserInput["role"]} */ ("patient") },
});
const { errors: fieldErrors } = form.formState;
@@ -12,6 +12,7 @@
import { getTranslations } from "next-intl/server";
import { InviteUserForm } from "./invite-user-form";
/** @returns {Promise<import("react").JSX.Element>} */
export default async function AdminInvitePage() {
const t = await getTranslations("admin.invite");
@@ -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<import("react").JSX.Element>}
*/
export default async function AdminLayout({ children, params }) {
const { locale } = await params;
// Redirects to /dashboard if role !== 'admin'.
@@ -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<import("react").JSX.Element>}
*/
export default async function EditMedicinePage({ params }) {
const { id } = await params;
const t = await getTranslations("admin.medicines");
const medId = Number(id);
@@ -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<never> {
/** @returns {Promise<never>} */
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<MedicineFormState> {
/**
* @param {MedicineFormState} _prev
* @param {FormData} formData
* @returns {Promise<MedicineFormState>}
*/
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<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (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<MedicineFormState> {
/**
* @param {MedicineFormState} _prev
* @param {FormData} formData
* @returns {Promise<MedicineFormState>}
*/
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<string, string[]>),
: /** @type {Record<string, string[]>} */ (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<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function deactivateMedicineAction(formData) {
const session = await getServerSession();
if (session?.role !== "admin") return;
@@ -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<MedicineFormState>;
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<MedicineFormState>,
* defaults: MedicineDefaults,
* }} props
*/
export function MedicineForm({ mode, action, defaults }) {
const t = useTranslations("admin.medicines");
const [state, dispatch, isPending] = useActionState<MedicineFormState, FormData>(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") => (
<div className="space-y-1.5">
<Label htmlFor={name}>{label}</Label>
<Input
@@ -2,7 +2,11 @@ import { getTranslations } from "next-intl/server";
import { MedicineForm } from "../medicine-form";
import { createMedicineAction } from "../actions";
export default async function NewMedicinePage({ params }: { params: Promise<{ locale: string }> }) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function NewMedicinePage({ params }) {
await params;
const t = await getTranslations("admin.medicines");
return (
@@ -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<import("react").JSX.Element>}
*/
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
<div className="mb-6 flex items-center justify-between gap-4">
<h1 className="text-foreground text-xl font-semibold">{t("title")}</h1>
<Button asChild size="lg">
<Link href="/admin/medicines/new" locale={locale as "vi" | "en"}>
<Link href="/admin/medicines/new" locale={/** @type {"vi" | "en"} */ (locale)}>
{t("new")}
</Link>
</Button>
@@ -45,7 +50,10 @@ export default async function MedicinesPage({ params }: { params: Promise<{ loca
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="outline">
<Link href={`/admin/medicines/${m.id}/edit`} locale={locale as "vi" | "en"}>
<Link
href={`/admin/medicines/${m.id}/edit`}
locale={/** @type {"vi" | "en"} */ (locale)}
>
{t("edit")}
</Link>
</Button>
@@ -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<import("react").JSX.Element>}
*/
export default async function ReportsPage({ params }) {
const { locale } = await params;
const t = await getTranslations("reports");
@@ -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<ServiceFormState> {
/**
* @param {ServiceFormState} _prev
* @param {FormData} formData
* @returns {Promise<ServiceFormState>}
*/
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<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (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<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
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<void> {
}
}
export async function deactivateServiceAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function deactivateServiceAction(formData) {
const session = await getServerSession();
if (session?.role !== "admin") return;
@@ -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<HTMLFormElement>(null);
const [state, dispatch, isPending] = useActionState<ServiceFormState, FormData>(
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();
@@ -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<import("react").JSX.Element>}
*/
export default async function ServicesPage({ params }) {
await params;
const t = await getTranslations("admin.services");
@@ -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<ClinicSettingsState> {
/** @param {string} s */
const emptyToNull = (s) => (s.length > 0 ? s : null);
/**
* @param {ClinicSettingsState} _prev
* @param {FormData} formData
* @returns {Promise<ClinicSettingsState>}
*/
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<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (parsed.error.flatten().fieldErrors),
formError: null,
};
}
@@ -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<ClinicSettingsState, FormData>(
updateClinicSettingsAction,
{ status: "idle" },
);
const [state, dispatchAction, isPending] = useActionState(updateClinicSettingsAction, {
status: "idle",
});
const form = useForm<ClinicSettingsInput>({
const form = useForm({
resolver: zodResolver(ClinicSettingsSchema),
mode: "onBlur",
defaultValues: defaults,
@@ -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<import("react").JSX.Element>}
*/
export default async function ClinicSettingsPage({ params }) {
await params; // Next.js 16: params is async.
const t = await getTranslations("admin.settings");
@@ -22,7 +22,11 @@ async function revalidateStaff() {
revalidatePath(`/${locale}/admin/staff`);
}
export async function updateStaffRoleAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
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<void> {
}
}
export async function removeStaffAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function removeStaffAction(formData) {
const session = await getServerSession();
if (session?.role !== "admin") return;
@@ -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<import("react").JSX.Element>}
*/
export default async function StaffPage({ params }) {
await params;
const t = await getTranslations("admin.staff");
const tRoles = await getTranslations("roles");
@@ -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<import("react").JSX.Element>}
*/
export default async function EditTemplatePage({ params }) {
const { id } = await params;
const t = await getTranslations("admin.templates");
const templateId = Number(id);
@@ -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<never> {
/** @returns {Promise<never>} */
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<TemplateFormState> {
/**
* @param {TemplateFormState} _prev
* @param {FormData} formData
* @returns {Promise<TemplateFormState>}
*/
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<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (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<TemplateFormState> {
/**
* @param {TemplateFormState} _prev
* @param {FormData} formData
* @returns {Promise<TemplateFormState>}
*/
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<string, string[]>),
: /** @type {Record<string, string[]>} */ (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<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function deactivateTemplateAction(formData) {
const session = await getServerSession();
if (session?.role !== "admin") return;
@@ -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<import("react").JSX.Element>}
*/
export default async function NewTemplatePage({ params }) {
await params;
const t = await getTranslations("admin.templates");
@@ -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<import("react").JSX.Element>}
*/
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
<div className="mb-6 flex items-center justify-between gap-4">
<h1 className="text-foreground text-xl font-semibold">{t("title")}</h1>
<Button asChild size="lg">
<Link href="/admin/templates/new" locale={locale as "vi" | "en"}>
<Link href="/admin/templates/new" locale={/** @type {"vi" | "en"} */ (locale)}>
{t("new")}
</Link>
</Button>
@@ -45,7 +49,10 @@ export default async function TemplatesPage({ params }: { params: Promise<{ loca
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="outline">
<Link href={`/admin/templates/${tpl.id}/edit`} locale={locale as "vi" | "en"}>
<Link
href={`/admin/templates/${tpl.id}/edit`}
locale={/** @type {"vi" | "en"} */ (locale)}
>
{t("edit")}
</Link>
</Button>
@@ -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<TemplateFormState>;
defaults: TemplateDefaults;
}) {
/**
* @param {{
* mode: "create" | "edit",
* action: (prev: TemplateFormState, formData: FormData) => Promise<TemplateFormState>,
* defaults: TemplateDefaults,
* }} props
*/
export function TemplateForm({ mode, action, defaults }) {
const t = useTranslations("admin.templates");
const [state, dispatch, isPending] = useActionState<TemplateFormState, FormData>(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 ? (
<p className="text-destructive text-sm" role="alert">
{fe[f][0]}
@@ -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<ImageActionState> {
/** @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<ImageActionState>}
*/
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<ImageActionState> {
/**
* @param {number} checkupId
* @param {number} imageId
* @param {string} storagePath
* @returns {Promise<ImageActionState>}
*/
export async function deleteImageAction(checkupId, imageId, storagePath) {
const t = await getTranslations("imaging");
const session = await getServerSession();
@@ -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<HTMLCanvasElement | null>(null);
const canvasRef = useRef(/** @type {HTMLCanvasElement | null} */ (null));
const [error, setError] = useState(false);
useEffect(() => {
@@ -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<Blob | null> {
/**
* @param {HTMLCanvasElement} canvas
* @param {number} quality
* @returns {Promise<Blob | null>}
*/
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<Blob | null> {
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<Blob | null>}
*/
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<Blob | null> {
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<HTMLVideoElement | null>(null);
const streamRef = useRef<MediaStream | null>(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<string | null>(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<HTMLInputElement>) {
/** @param {import("react").ChangeEvent<HTMLInputElement>} e */
function handleFileChange(e) {
const file = e.target.files?.[0];
e.target.value = ""; // allow re-selecting the same file
if (!file) return;
@@ -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<number | null>(null);
const [error, setError] = useState<string | null>(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 () => {
@@ -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<import("react").JSX.Element>}
*/
export default async function ImagingPage({ params }) {
const { locale, id } = await params;
const t = await getTranslations("imaging");
const tReports = await getTranslations("reports");
@@ -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,
@@ -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<import("react").JSX.Element>}
*/
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({
</div>
<div className="flex gap-2">
<Button asChild variant="outline">
<Link href={`/checkups/${c.id}/prescription`} locale={locale as "vi" | "en"}>
<Link
href={`/checkups/${c.id}/prescription`}
locale={/** @type {"vi" | "en"} */ (locale)}
>
{tBilling("title")}
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/checkups/${c.id}/imaging`} locale={locale as "vi" | "en"}>
<Link href={`/checkups/${c.id}/imaging`} locale={/** @type {"vi" | "en"} */ (locale)}>
{tImaging("title")}
</Link>
</Button>
@@ -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<PrescriptionSaveState> {
/**
* @param {PrescriptionSaveState} _prev
* @param {FormData} formData
* @returns {Promise<PrescriptionSaveState>}
*/
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<MarkPaidState> {
/**
* @param {MarkPaidState} _prev
* @param {FormData} formData
* @returns {Promise<MarkPaidState>}
*/
export async function markPaidAction(_prev, formData) {
const t = await getTranslations("billing");
const session = await getServerSession();
@@ -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<import("react").JSX.Element>}
*/
export default async function PrescriptionPage({ params }) {
const { locale, id } = await params;
const t = await getTranslations("billing");
const tReports = await getTranslations("reports");
@@ -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 ?? "—",
@@ -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<MedicineRow[]>(() =>
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<ServiceRow[]>(() =>
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<PrescriptionSaveState, FormData>(
savePrescriptionAction,
{ status: "idle" },
);
const [payState, payDispatch, isPaying] = useActionState<MarkPaidState, FormData>(
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 = <K extends keyof MedicineRow>(
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 = <K extends keyof ServiceRow>(
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),
@@ -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<UltrasoundImage | null> => {
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<UltrasoundImage | null>} */
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({
@@ -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<CheckupSaveState> {
/** @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<CheckupSaveState>}
*/
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<string, string[]>),
: /** @type {Record<string, string[]>} */ (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<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function deleteCheckupAction(formData) {
const session = await getServerSession();
if (!isClinical(session?.role)) return;
@@ -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<CheckupSaveState, FormData>(
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<HTMLTextAreaElement>(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<Record<string, string>>(() =>
Object.fromEntries(initialTemplateValues.map((v) => [v.label, v.value])),
const [templateFieldValues, setTemplateFieldValues] = useState(
/** @type {Record<string, string>} */ (
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) => (
<div className="space-y-1.5">
<Label htmlFor={name}>{label}</Label>
<Input id={name} name={name} defaultValue={defaults[name]} disabled={isPending} />
</div>
);
const area = (
name: keyof CheckupDefaults,
label: string,
ref?: RefObject<HTMLTextAreaElement | null>,
) => (
/**
* @param {keyof CheckupDefaults} name
* @param {string} label
* @param {import("react").RefObject<HTMLTextAreaElement | null>} [ref]
*/
const area = (name, label, ref) => (
<div className="space-y-1.5">
<Label htmlFor={name}>{label}</Label>
<textarea
@@ -6,16 +6,19 @@
* window.confirm(), and cancels (preventDefault) if the user declines.
*/
import type { FormEvent } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { deleteCheckupAction } from "./actions";
export function DeleteCheckupButton({ checkupId }: { checkupId: number }) {
/**
* @param {{ checkupId: number }} props
*/
export function DeleteCheckupButton({ checkupId }) {
const t = useTranslations("checkups");
function onSubmit(e: FormEvent<HTMLFormElement>) {
/** @param {import("react").FormEvent<HTMLFormElement>} e */
function onSubmit(e) {
if (!window.confirm(t("confirmDelete"))) {
e.preventDefault();
}
@@ -1,15 +1,12 @@
// WARNING: Do NOT add `'use cache'` requireRole() reads cookies().
import type { ReactNode } from "react";
import { requireRole } from "@/lib/auth/require-role";
export default async function RemindersLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
/**
* @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function CheckupsLayout({ children, params }) {
const { locale } = await params;
await requireRole(["admin", "receptionist", "doctor", "nurse"], locale);
return <>{children}</>;
@@ -13,7 +13,11 @@ import { formatVnd, sumLineTotals } from "@/lib/billing/totals";
const vnd = formatVnd;
export default async function DashboardPage({ params }: { params: Promise<{ locale: string }> }) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function DashboardPage({ params }) {
await params;
const t = await getTranslations("dashboard");
@@ -56,7 +60,9 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
.gte("checkup_date", days[0])
.lte("checkup_date", today)
.eq("deleted", false)
: Promise.resolve({ data: [] as { id: number; checkup_date: string }[] }),
: Promise.resolve({
data: /** @type {{ id: number, checkup_date: string }[]} */ ([]),
}),
]);
let revenue7d = days.map((d) => ({ day: d.slice(5), amount: 0 }));
@@ -74,7 +80,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ loca
]);
const paid = new Set((paidOrders ?? []).map((o) => o.checkup_id));
const dateOf = new Map(weekCheckups.map((c) => [c.id, c.checkup_date]));
const totalByCheckup = new Map<number, number>();
const totalByCheckup = /** @type {Map<number, number>} */ (new Map());
for (const r of [...(oi ?? []), ...(cs ?? [])]) {
totalByCheckup.set(r.checkup_id, (totalByCheckup.get(r.checkup_id) ?? 0) + r.line_total);
}
@@ -8,7 +8,8 @@ import { formatVnd, formatVndCompact } from "@/lib/billing/totals";
const vnd = formatVnd;
const compact = formatVndCompact;
export function RevenueChart({ data }: { data: { day: string; amount: number }[] }) {
/** @param {{ data: { day: string, amount: number }[] }} props */
export function RevenueChart({ data }) {
return (
<div className="h-56 w-full text-current">
<ResponsiveContainer width="100%" height="100%">
@@ -16,19 +16,16 @@
* "authed but unenrolled" edge case the proxy cannot detect.
*/
import type { ReactNode } from "react";
import { redirect } from "@/i18n/navigation";
import { getServerSession } from "@/lib/auth/get-server-session";
import { signOutAction } from "@/app/[locale]/(auth)/sign-in/actions";
import { AppShell } from "@/components/app-shell/app-shell";
export default async function AppLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
/**
* @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element | null>}
*/
export default async function AppLayout({ children, params }) {
const { locale } = await params;
const session = await getServerSession();
@@ -3,6 +3,7 @@
* (respects prefers-reduced-motion via the global rule in globals.css).
*/
/** App route-segment loading skeleton. @returns {import("react").JSX.Element} */
export default function AppLoading() {
return (
<div className="flex min-h-[50vh] items-center justify-center" role="status" aria-live="polite">
@@ -6,11 +6,11 @@ import { createSupabaseServerClient } from "@/lib/supabase/server";
import { PatientForm } from "../../patient-form";
import { updateCustomerAction } from "../../actions";
export default async function EditPatientPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
/**
* @param {{ params: Promise<{ locale: string, id: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function EditPatientPage({ params }) {
const { id } = await params;
const t = await getTranslations("patients");
const customerId = Number(id);
@@ -12,17 +12,18 @@ import { Link } from "@/i18n/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { Button } from "@/components/ui/button";
const STATUS_STYLE: Record<string, string> = {
/** @type {Record<string, string>} */
const STATUS_STYLE = {
waiting: "bg-muted text-foreground",
in_progress: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
done: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200",
};
export default async function PatientDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
/**
* @param {{ params: Promise<{ locale: string, id: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function PatientDetailPage({ params }) {
const { locale, id } = await params;
const t = await getTranslations("patients");
const tCheckups = await getTranslations("checkups");
@@ -61,7 +62,7 @@ export default async function PatientDetailPage({
const rows = checkups ?? [];
const doctorIds = [
...new Set(rows.map((r) => r.doctor_id).filter((d): d is number => d != null)),
...new Set(rows.map((r) => r.doctor_id).filter(/** @returns {d is number} */ (d) => d != null)),
];
const { data: doctors } = doctorIds.length
? await supabase.from("doctors").select("id, last_name, first_name").in("id", doctorIds)
@@ -91,7 +92,7 @@ export default async function PatientDetailPage({
</p>
</div>
<Button asChild variant="outline">
<Link href={`/patients/${customer.id}/edit`} locale={locale as "vi" | "en"}>
<Link href={`/patients/${customer.id}/edit`} locale={/** @type {"vi" | "en"} */ (locale)}>
{t("edit")}
</Link>
</Button>
@@ -106,7 +107,7 @@ export default async function PatientDetailPage({
<li key={c.id}>
<Link
href={`/checkups/${c.id}`}
locale={locale as "vi" | "en"}
locale={/** @type {"vi" | "en"} */ (locale)}
className="hover:bg-accent -mx-2 flex items-center justify-between gap-3 rounded-md px-2 py-3"
>
<div className="min-w-0">
@@ -13,19 +13,23 @@ 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 {
CustomerSchema,
type CustomerFormState,
type CustomerInput,
} from "@/lib/customers/customer-schema";
import { CustomerSchema } from "@/lib/customers/customer-schema";
const CLINICAL_ROLES: AppRole[] = ["admin", "receptionist", "doctor", "nurse"];
const isClinical = (role: AppRole | null | undefined) => !!role && CLINICAL_ROLES.includes(role);
const nullIfBlank = (s: string) => (s.trim().length ? s.trim() : null);
/** @typedef {import('@/lib/db/roles').AppRole} AppRole */
/** @typedef {import('@/lib/customers/customer-schema').CustomerFormState} CustomerFormState */
/** @typedef {import('@/lib/customers/customer-schema').CustomerInput} CustomerInput */
function readForm(formData: FormData) {
const get = (k: string) => String(formData.get(k) ?? "");
/** @type {AppRole[]} */
const CLINICAL_ROLES = ["admin", "receptionist", "doctor", "nurse"];
/** @param {AppRole | null | undefined} role */
const isClinical = (role) => !!role && CLINICAL_ROLES.includes(role);
/** @param {string} s */
const nullIfBlank = (s) => (s.trim().length ? s.trim() : null);
/** @param {FormData} formData */
function readForm(formData) {
/** @param {string} k */
const get = (k) => String(formData.get(k) ?? "");
return {
lastName: get("lastName"),
firstName: get("firstName"),
@@ -39,7 +43,8 @@ function readForm(formData: FormData) {
};
}
function toRow(d: CustomerInput) {
/** @param {CustomerInput} d */
function toRow(d) {
return {
first_name: d.firstName,
last_name: d.lastName,
@@ -54,10 +59,12 @@ function toRow(d: CustomerInput) {
}
// ── Create (useActionState-compatible; redirects to the list on success) ─────
export async function createCustomerAction(
_prev: CustomerFormState,
formData: FormData,
): Promise<CustomerFormState> {
/**
* @param {CustomerFormState} _prev
* @param {FormData} formData
* @returns {Promise<CustomerFormState>}
*/
export async function createCustomerAction(_prev, formData) {
const t = await getTranslations("patients");
const session = await getServerSession();
@@ -69,7 +76,7 @@ export async function createCustomerAction(
if (!parsed.success) {
return {
status: "error",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (parsed.error.flatten().fieldErrors),
formError: null,
};
}
@@ -97,10 +104,12 @@ export async function createCustomerAction(
}
// ── Update ───────────────────────────────────────────────────────────────────
export async function updateCustomerAction(
_prev: CustomerFormState,
formData: FormData,
): Promise<CustomerFormState> {
/**
* @param {CustomerFormState} _prev
* @param {FormData} formData
* @returns {Promise<CustomerFormState>}
*/
export async function updateCustomerAction(_prev, formData) {
const t = await getTranslations("patients");
const session = await getServerSession();
@@ -115,7 +124,7 @@ export async function updateCustomerAction(
status: "error",
fieldErrors: parsed.success
? {}
: (parsed.error.flatten().fieldErrors as Record<string, string[]>),
: /** @type {Record<string, string[]>} */ (parsed.error.flatten().fieldErrors),
formError: parsed.success ? t("errorGeneric") : null,
};
}
@@ -138,7 +147,11 @@ export async function updateCustomerAction(
}
// ── Soft-delete (deactivate) ─────────────────────────────────────────────────
export async function deactivateCustomerAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function deactivateCustomerAction(formData) {
const session = await getServerSession();
if (!isClinical(session?.role)) return;
@@ -159,9 +172,11 @@ export async function deactivateCustomerAction(formData: FormData): Promise<void
}
// ── Wards for a province (cascading address dropdown) ─────────────────────────
export async function getWardsAction(
provinceCode: string,
): Promise<{ code: string; name: string }[]> {
/**
* @param {string} provinceCode
* @returns {Promise<{ code: string, name: string }[]>}
*/
export async function getWardsAction(provinceCode) {
const session = await getServerSession();
if (!session?.role || !provinceCode) return [];
@@ -6,16 +6,13 @@
* /dashboard by requireRole.
*/
import type { ReactNode } from "react";
import { requireRole } from "@/lib/auth/require-role";
export default async function PatientsLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
/**
* @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function PatientsLayout({ children, params }) {
const { locale } = await params;
await requireRole(["admin", "receptionist", "doctor", "nurse"], locale);
return <>{children}</>;
@@ -17,7 +17,11 @@ const BLANK = {
addressDetail: "",
};
export default async function NewPatientPage({ params }: { params: Promise<{ locale: string }> }) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function NewPatientPage({ params }) {
await params;
const t = await getTranslations("patients");
@@ -13,27 +13,22 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { deactivateCustomerAction } from "./actions";
export default async function PatientsPage({
params,
searchParams,
}: {
params: Promise<{ locale: string }>;
searchParams: Promise<{ q?: string }>;
}) {
/**
* @typedef {{ id: number, first_name: string, last_name: string, phone: string | null, dob: string | null }} PatientRow
*/
/**
* @param {{ params: Promise<{ locale: string }>, searchParams: Promise<{ q?: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function PatientsPage({ params, searchParams }) {
const { locale } = await params;
const { q = "" } = await searchParams;
const t = await getTranslations("patients");
const supabase = await createSupabaseServerClient();
const { data } = await supabase.rpc("search_customers", { q });
type PatientRow = {
id: number;
first_name: string;
last_name: string;
phone: string | null;
dob: string | null;
};
const patients = (data ?? []) as PatientRow[];
const patients = /** @type {PatientRow[]} */ (data ?? []);
// Recently seen: most recent distinct customers by their latest checkup.
const { data: recentCheckups } = await supabase
@@ -44,8 +39,10 @@ export default async function PatientsPage({
.order("id", { ascending: false })
.limit(50);
const seenIds = new Set<number>();
const recentIds: number[] = [];
/** @type {Set<number>} */
const seenIds = new Set();
/** @type {number[]} */
const recentIds = [];
for (const row of recentCheckups ?? []) {
if (!seenIds.has(row.customer_id)) {
seenIds.add(row.customer_id);
@@ -67,14 +64,14 @@ export default async function PatientsPage({
);
const recentlySeen = recentIds
.filter((cid) => recentNames.has(cid))
.map((cid) => ({ id: cid, name: recentNames.get(cid)! }));
.map((cid) => ({ id: cid, name: /** @type {string} */ (recentNames.get(cid)) }));
return (
<div className="mx-auto max-w-3xl px-4 py-10">
<div className="mb-6 flex items-center justify-between gap-4">
<h1 className="text-foreground text-xl font-semibold">{t("title")}</h1>
<Button asChild size="lg">
<Link href="/patients/new" locale={locale as "vi" | "en"}>
<Link href="/patients/new" locale={/** @type {"vi" | "en"} */ (locale)}>
{t("new")}
</Link>
</Button>
@@ -90,7 +87,7 @@ export default async function PatientsPage({
<Link
key={p.id}
href={`/patients/${p.id}`}
locale={locale as "vi" | "en"}
locale={/** @type {"vi" | "en"} */ (locale)}
className="border-border bg-muted/50 hover:bg-accent rounded-full border px-3 py-1 text-sm"
>
{p.name}
@@ -121,7 +118,7 @@ export default async function PatientsPage({
<div className="min-w-0">
<Link
href={`/patients/${p.id}`}
locale={locale as "vi" | "en"}
locale={/** @type {"vi" | "en"} */ (locale)}
className="text-foreground block truncate font-medium hover:underline"
>
{p.last_name} {p.first_name}
@@ -132,7 +129,10 @@ export default async function PatientsPage({
</div>
<div className="flex shrink-0 items-center gap-1">
<Button asChild variant="outline">
<Link href={`/patients/${p.id}/edit`} locale={locale as "vi" | "en"}>
<Link
href={`/patients/${p.id}/edit`}
locale={/** @type {"vi" | "en"} */ (locale)}
>
{t("edit")}
</Link>
</Button>
@@ -14,50 +14,50 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getWardsAction } from "./actions";
import type { CustomerFormState } from "@/lib/customers/customer-schema";
type Option = { code: string; name: string };
/** @typedef {import('@/lib/customers/customer-schema').CustomerFormState} CustomerFormState */
export type PatientDefaults = {
id?: number;
lastName: string;
firstName: string;
dob: string;
gender: string;
phone: string;
cccd: string;
provinceCode: string;
wardCode: string;
addressDetail: string;
};
/** @typedef {{ code: string, name: string }} Option */
/**
* @typedef {object} PatientDefaults
* @property {number} [id]
* @property {string} lastName
* @property {string} firstName
* @property {string} dob
* @property {string} gender
* @property {string} phone
* @property {string} cccd
* @property {string} provinceCode
* @property {string} wardCode
* @property {string} addressDetail
*/
const SELECT_CLASS =
"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";
export function PatientForm({
mode,
action,
provinces,
initialWards,
defaults,
}: {
mode: "create" | "edit";
action: (prev: CustomerFormState, formData: FormData) => Promise<CustomerFormState>;
provinces: Option[];
initialWards: Option[];
defaults: PatientDefaults;
}) {
/**
* @param {{
* mode: "create" | "edit",
* action: (prev: CustomerFormState, formData: FormData) => Promise<CustomerFormState>,
* provinces: Option[],
* initialWards: Option[],
* defaults: PatientDefaults,
* }} props
*/
export function PatientForm({ mode, action, provinces, initialWards, defaults }) {
const t = useTranslations("patients");
const [state, dispatch, isPending] = useActionState<CustomerFormState, FormData>(action, {
const [state, dispatch, isPending] = useActionState(action, {
status: "idle",
});
const [province, setProvince] = useState(defaults.provinceCode);
const [ward, setWard] = useState(defaults.wardCode);
const [wards, setWards] = useState<Option[]>(initialWards);
const [wards, setWards] = useState(/** @type {Option[]} */ (initialWards));
const [loadingWards, startWards] = useTransition();
function onProvinceChange(code: string) {
/** @param {string} code */
function onProvinceChange(code) {
setProvince(code);
setWard("");
startWards(async () => setWards(await getWardsAction(code)));
@@ -66,7 +66,8 @@ export function PatientForm({
const fe = state.status === "error" ? state.fieldErrors : {};
const formError = state.status === "error" ? state.formError : null;
const err = (field: string) =>
/** @param {string} field */
const err = (field) =>
fe[field]?.length ? (
<p className="text-destructive text-sm" role="alert">
{fe[field][0]}
@@ -13,24 +13,28 @@ 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 {
RegisterCheckupSchema,
SetQueueCounterSchema,
type RegisterCheckupState,
type SetQueueCounterState,
} from "@/lib/checkups/checkup-schema";
import { RegisterCheckupSchema, SetQueueCounterSchema } from "@/lib/checkups/checkup-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/checkups/checkup-schema').RegisterCheckupState} RegisterCheckupState */
/** @typedef {import('@/lib/checkups/checkup-schema').SetQueueCounterState} SetQueueCounterState */
const COUNTER_MANAGERS: AppRole[] = ["admin", "receptionist"];
const canManageCounter = (r: AppRole | null | undefined) => !!r && COUNTER_MANAGERS.includes(r);
/** @type {AppRole[]} */
const CLINICAL = ["admin", "receptionist", "doctor", "nurse"];
/** @param {AppRole | null | undefined} r */
const isClinical = (r) => !!r && CLINICAL.includes(r);
export async function registerCheckupAction(
_prev: RegisterCheckupState,
formData: FormData,
): Promise<RegisterCheckupState> {
/** @type {AppRole[]} */
const COUNTER_MANAGERS = ["admin", "receptionist"];
/** @param {AppRole | null | undefined} r */
const canManageCounter = (r) => !!r && COUNTER_MANAGERS.includes(r);
/**
* @param {RegisterCheckupState} _prev
* @param {FormData} formData
* @returns {Promise<RegisterCheckupState>}
*/
export async function registerCheckupAction(_prev, formData) {
const t = await getTranslations("queue");
const session = await getServerSession();
@@ -47,7 +51,7 @@ export async function registerCheckupAction(
if (!parsed.success) {
return {
status: "error",
fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
fieldErrors: /** @type {Record<string, string[]>} */ (parsed.error.flatten().fieldErrors),
formError: null,
};
}
@@ -87,10 +91,12 @@ export async function registerCheckupAction(
* resetting after a printer jam, etc). admin/receptionist only RLS +
* set_queue_counter's role check enforce it; this is defense-in-depth.
*/
export async function setQueueCounterAction(
_prev: SetQueueCounterState,
formData: FormData,
): Promise<SetQueueCounterState> {
/**
* @param {SetQueueCounterState} _prev
* @param {FormData} formData
* @returns {Promise<SetQueueCounterState>}
*/
export async function setQueueCounterAction(_prev, formData) {
const t = await getTranslations("queue");
const session = await getServerSession();
@@ -135,7 +141,11 @@ export async function setQueueCounterAction(
* and marks it in_progress. On success, redirects straight to the checkup
* screen so the doctor never has to find the row in the list themselves.
*/
export async function callNextPatientAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function callNextPatientAction(formData) {
const session = await getServerSession();
if (!isClinical(session?.role)) return;
@@ -161,7 +171,11 @@ export async function callNextPatientAction(formData: FormData): Promise<void> {
}
}
export async function callPatientAction(formData: FormData): Promise<void> {
/**
* @param {FormData} formData
* @returns {Promise<void>}
*/
export async function callPatientAction(formData) {
const session = await getServerSession();
if (!isClinical(session?.role)) return;
@@ -16,28 +16,22 @@ import { callNextPatientAction } from "./actions";
const TYPING_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
export function CallNextButton({
shiftId,
shiftLabel,
waitingCount,
enableShortcut,
}: {
shiftId: number;
shiftLabel: string;
waitingCount: number;
enableShortcut: boolean;
}) {
/**
* @param {{ shiftId: number, shiftLabel: string, waitingCount: number, enableShortcut: boolean }} props
*/
export function CallNextButton({ shiftId, shiftLabel, waitingCount, enableShortcut }) {
const t = useTranslations("queue");
const formRef = useRef<HTMLFormElement>(null);
const formRef = useRef(/** @type {HTMLFormElement | null} */ (null));
useEffect(() => {
if (!enableShortcut) return;
function onKeyDown(event: KeyboardEvent) {
/** @param {KeyboardEvent} event */
function onKeyDown(event) {
if (!event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
if (event.key.toLowerCase() !== "n") return;
const target = event.target as HTMLElement | null;
const target = /** @type {HTMLElement | null} */ (event.target);
if (target && (TYPING_TAGS.has(target.tagName) || target.isContentEditable)) return;
event.preventDefault();
@@ -11,15 +11,16 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { SetQueueCounterState } from "@/lib/checkups/checkup-schema";
import { setQueueCounterAction } from "./actions";
export function CounterForm({ shiftId, currentValue }: { shiftId: number; currentValue: number }) {
/** @typedef {import('@/lib/checkups/checkup-schema').SetQueueCounterState} SetQueueCounterState */
/**
* @param {{ shiftId: number, currentValue: number }} props
*/
export function CounterForm({ shiftId, currentValue }) {
const t = useTranslations("queue");
const [state, dispatch, isPending] = useActionState<SetQueueCounterState, FormData>(
setQueueCounterAction,
{ status: "idle" },
);
const [state, dispatch, isPending] = useActionState(setQueueCounterAction, { status: "idle" });
return (
<form action={dispatch} className="flex items-center gap-2">
@@ -1,15 +1,12 @@
// WARNING: Do NOT add `'use cache'` requireRole() reads cookies().
import type { ReactNode } from "react";
import { requireRole } from "@/lib/auth/require-role";
export default async function QueueLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
/**
* @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function QueueLayout({ children, params }) {
const { locale } = await params;
await requireRole(["admin", "receptionist", "doctor", "nurse"], locale);
return <>{children}</>;
@@ -18,13 +18,18 @@ import { callPatientAction } from "./actions";
const COUNTER_MANAGERS = new Set(["admin", "receptionist"]);
const STATUS_STYLE: Record<string, string> = {
/** @type {Record<string, string>} */
const STATUS_STYLE = {
waiting: "bg-muted text-foreground",
in_progress: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
done: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200",
};
export default async function QueuePage({ params }: { params: Promise<{ locale: string }> }) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function QueuePage({ params }) {
const { locale } = await params;
const t = await getTranslations("queue");
const tShift = await getTranslations("shifts");
@@ -71,7 +76,8 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
const shiftCode = new Map((shifts ?? []).map((s) => [s.id, s.code]));
const counterByShift = new Map((counters ?? []).map((c) => [c.shift_id, c.last_number]));
const waitingCountByShift = new Map<number, number>();
/** @type {Map<number, number>} */
const waitingCountByShift = new Map();
for (const r of rows) {
if (r.status === "waiting" && r.shift_id != null) {
waitingCountByShift.set(r.shift_id, (waitingCountByShift.get(r.shift_id) ?? 0) + 1);
@@ -128,7 +134,8 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
<h2 className="text-foreground mb-3 text-sm font-medium">{t("registerTitle")}</h2>
<RegisterForm
patients={(patients ?? []).map(
(p: { id: number; last_name: string; first_name: string }) => ({
/** @param {{ id: number, last_name: string, first_name: string }} p */
(p) => ({
id: p.id,
last_name: p.last_name,
first_name: p.first_name,
@@ -158,7 +165,7 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
</p>
<p className="text-muted-foreground truncate text-xs">
{shiftCode.get(c.shift_id ?? -1)
? tShift(shiftCode.get(c.shift_id ?? -1)!)
? tShift(/** @type {string} */ (shiftCode.get(c.shift_id ?? -1)))
: "—"}
{c.doctor_id ? ` · ${docName.get(c.doctor_id) ?? "—"}` : ""}
</p>
@@ -179,7 +186,7 @@ export default async function QueuePage({ params }: { params: Promise<{ locale:
</form>
)}
<Button asChild variant="outline">
<Link href={`/checkups/${c.id}`} locale={locale as "vi" | "en"}>
<Link href={`/checkups/${c.id}`} locale={/** @type {"vi" | "en"} */ (locale)}>
{t("open")}
</Link>
</Button>
@@ -20,7 +20,7 @@ import { useTranslations } from "next-intl";
import { Wifi, WifiOff } from "lucide-react";
import { createSupabaseBrowserClient } from "@/lib/supabase/client";
type ConnectionStatus = "live" | "disconnected";
/** @typedef {"live" | "disconnected"} ConnectionStatus */
const timeFormatter = new Intl.DateTimeFormat("vi-VN", {
hour: "2-digit",
@@ -29,11 +29,12 @@ const timeFormatter = new Intl.DateTimeFormat("vi-VN", {
hour12: false,
});
/** Realtime connection badge: subscribes to queue changes and refreshes the route on updates. @returns {import("react").JSX.Element} */
export function QueueRealtime() {
const router = useRouter();
const t = useTranslations("queue");
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
const [status, setStatus] = useState(/** @type {ConnectionStatus} */ ("disconnected"));
const [lastUpdatedAt, setLastUpdatedAt] = useState(/** @type {string | null} */ (null));
useEffect(() => {
const supabase = createSupabaseBrowserClient();
@@ -12,33 +12,26 @@ 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 { RegisterCheckupState } from "@/lib/checkups/checkup-schema";
import { registerCheckupAction } from "./actions";
type Patient = { id: number; last_name: string; first_name: string };
type Shift = { id: number; code: string };
type Doctor = { id: number; last_name: string; first_name: string };
/** @typedef {import('@/lib/checkups/checkup-schema').RegisterCheckupState} RegisterCheckupState */
/** @typedef {{ id: number, last_name: string, first_name: string }} Patient */
/** @typedef {{ id: number, code: string }} Shift */
/** @typedef {{ id: number, last_name: string, first_name: string }} Doctor */
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";
export function RegisterForm({
patients,
shifts,
doctors,
}: {
patients: Patient[];
shifts: Shift[];
doctors: Doctor[];
}) {
/**
* @param {{ patients: Patient[], shifts: Shift[], doctors: Doctor[] }} props
*/
export function RegisterForm({ patients, shifts, doctors }) {
const t = useTranslations("queue");
const tShift = useTranslations("shifts");
const formRef = useRef<HTMLFormElement>(null);
const formRef = useRef(/** @type {HTMLFormElement | null} */ (null));
const [state, dispatch, isPending] = useActionState<RegisterCheckupState, FormData>(
registerCheckupAction,
{ status: "idle" },
);
const [state, dispatch, isPending] = useActionState(registerCheckupAction, { status: "idle" });
useEffect(() => {
if (state.status === "success") formRef.current?.reset();
@@ -1,15 +1,12 @@
// WARNING: Do NOT add `'use cache'` requireRole() reads cookies().
import type { ReactNode } from "react";
import { requireRole } from "@/lib/auth/require-role";
export default async function CheckupsLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
/**
* @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function RemindersLayout({ children, params }) {
const { locale } = await params;
await requireRole(["admin", "receptionist", "doctor", "nurse"], locale);
return <>{children}</>;
@@ -8,7 +8,11 @@
import { getTranslations } from "next-intl/server";
import { createSupabaseServerClient } from "@/lib/supabase/server";
export default async function RemindersPage({ params }: { params: Promise<{ locale: string }> }) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function RemindersPage({ params }) {
await params;
const t = await getTranslations("reminders");
@@ -13,6 +13,7 @@ import { createSupabaseServerClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Streams the catalog report as an XLSX download. @returns {Promise<Response>} */
export async function GET() {
const session = await getServerSession();
if (session?.role !== "admin" && session?.role !== "cashier") {
@@ -53,7 +54,7 @@ export async function GET() {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(medicineRows), t("medicinesSheet"));
XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(serviceRows), t("servicesSheet"));
const buffer: Buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
const buffer = /** @type {Buffer} */ (XLSX.write(wb, { type: "buffer", bookType: "xlsx" }));
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh" }).format(
new Date(),
@@ -13,6 +13,7 @@ import { createSupabaseServerClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Streams the patients report as an XLSX download. @returns {Promise<Response>} */
export async function GET() {
const session = await getServerSession();
if (session?.role !== "admin" && session?.role !== "cashier") {
@@ -32,21 +33,21 @@ export async function GET() {
.order("last_name", { ascending: true })
.order("first_name", { ascending: true });
/** @param {string | null} v
* @returns {v is string} */
const isString = (v) => v != null;
const rows = customers ?? [];
const provinceCodes = [
...new Set(rows.map((r) => r.province_code).filter((v): v is string => v != null)),
];
const wardCodes = [
...new Set(rows.map((r) => r.ward_code).filter((v): v is string => v != null)),
];
const provinceCodes = [...new Set(rows.map((r) => r.province_code).filter(isString))];
const wardCodes = [...new Set(rows.map((r) => r.ward_code).filter(isString))];
const [{ data: provinces }, { data: wards }] = await Promise.all([
provinceCodes.length
? supabase.from("provinces").select("code, name").in("code", provinceCodes)
: Promise.resolve({ data: [] as { code: string; name: string }[] }),
: Promise.resolve({ data: /** @type {{ code: string, name: string }[]} */ ([]) }),
wardCodes.length
? supabase.from("wards").select("code, name").in("code", wardCodes)
: Promise.resolve({ data: [] as { code: string; name: string }[] }),
: Promise.resolve({ data: /** @type {{ code: string, name: string }[]} */ ([]) }),
]);
const provinceName = new Map((provinces ?? []).map((p) => [p.code, p.name]));
@@ -71,7 +72,7 @@ export async function GET() {
const ws = XLSX.utils.json_to_sheet(sheetRows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Patients");
const buffer: Buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
const buffer = /** @type {Buffer} */ (XLSX.write(wb, { type: "buffer", bookType: "xlsx" }));
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh" }).format(
new Date(),
@@ -16,7 +16,11 @@ export const dynamic = "force-dynamic";
const MONTH_RE = /^\d{4}-\d{2}$/;
function monthRange(month: string): { start: string; end: string } {
/**
* @param {string} month
* @returns {{ start: string, end: string }}
*/
function monthRange(month) {
const parts = month.split("-");
const y = Number(parts[0] ?? 0);
const m = Number(parts[1] ?? 1);
@@ -27,7 +31,8 @@ function monthRange(month: string): { start: string; end: string } {
return { start, end };
}
export async function GET(req: Request) {
/** @param {Request} req */
export async function GET(req) {
const session = await getServerSession();
if (session?.role !== "admin" && session?.role !== "cashier") {
return new Response("Forbidden", { status: 403 });
@@ -69,7 +74,7 @@ export async function GET(req: Request) {
.in("checkup_id", ids)
.eq("payment_status", "paid")
: {
data: [] as { checkup_id: number; payment_status: string; payment_method: string | null }[],
data: /** @type {{ checkup_id: number, payment_status: string, payment_method: string | null }[]} */ ([]),
};
const paidIds = new Set((orders ?? []).map((o) => o.checkup_id));
@@ -81,43 +86,51 @@ export async function GET(req: Request) {
const [{ data: custs }, { data: items }, { data: svcs }] = await Promise.all([
custIds.length
? supabase.from("customers").select("id, last_name, first_name").in("id", custIds)
: Promise.resolve({ data: [] as { id: number; last_name: string; first_name: string }[] }),
: Promise.resolve({
data: /** @type {{ id: number, last_name: string, first_name: string }[]} */ ([]),
}),
paidCheckupIds.length
? supabase
.from("order_items")
.select("checkup_id, line_total")
.in("checkup_id", paidCheckupIds)
: Promise.resolve({ data: [] as { checkup_id: number; line_total: number }[] }),
: Promise.resolve({
data: /** @type {{ checkup_id: number, line_total: number }[]} */ ([]),
}),
paidCheckupIds.length
? supabase
.from("checkup_services")
.select("checkup_id, line_total")
.in("checkup_id", paidCheckupIds)
: Promise.resolve({ data: [] as { checkup_id: number; line_total: number }[] }),
: Promise.resolve({
data: /** @type {{ checkup_id: number, line_total: number }[]} */ ([]),
}),
]);
const name = new Map((custs ?? []).map((c) => [c.id, `${c.last_name} ${c.first_name}`]));
const medSubtotalBy = new Map<number, number>();
const medSubtotalBy = /** @type {Map<number, number>} */ (new Map());
for (const it of items ?? []) {
medSubtotalBy.set(it.checkup_id, (medSubtotalBy.get(it.checkup_id) ?? 0) + it.line_total);
}
const svcSubtotalBy = new Map<number, number>();
const svcSubtotalBy = /** @type {Map<number, number>} */ (new Map());
for (const sv of svcs ?? []) {
svcSubtotalBy.set(sv.checkup_id, (svcSubtotalBy.get(sv.checkup_id) ?? 0) + sv.line_total);
}
const KNOWN_METHODS = ["cash", "card", "transfer"] as const;
const methodLabel = (method: string | null) => {
const KNOWN_METHODS = /** @type {const} */ (["cash", "card", "transfer"]);
/** @param {string | null} method */
const methodLabel = (method) => {
if (!method) return "";
return (KNOWN_METHODS as readonly string[]).includes(method)
return /** @type {readonly string[]} */ (KNOWN_METHODS).includes(method)
? tBilling(`method.${method}`)
: method;
};
type RevenueRow = Record<string, string | number>;
/** @typedef {Record<string, string | number>} RevenueRow */
let grandTotal = 0;
const sheetRows: RevenueRow[] = paidRows.map((r) => {
/** @type {RevenueRow[]} */
const sheetRows = paidRows.map((r) => {
const medSubtotal = medSubtotalBy.get(r.id) ?? 0;
const svcSubtotal = svcSubtotalBy.get(r.id) ?? 0;
const total = medSubtotal + svcSubtotal;
@@ -144,7 +157,7 @@ export async function GET(req: Request) {
const ws = XLSX.utils.json_to_sheet(sheetRows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Revenue");
const buffer: Buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
const buffer = /** @type {Buffer} */ (XLSX.write(wb, { type: "buffer", bookType: "xlsx" }));
return new Response(new Uint8Array(buffer), {
headers: {
@@ -14,7 +14,11 @@ export const dynamic = "force-dynamic";
const MONTH_RE = /^\d{4}-\d{2}$/;
function monthRange(month: string): { start: string; end: string } {
/**
* @param {string} month
* @returns {{ start: string, end: string }}
*/
function monthRange(month) {
const parts = month.split("-");
const y = Number(parts[0] ?? 0);
const m = Number(parts[1] ?? 1);
@@ -25,7 +29,8 @@ function monthRange(month: string): { start: string; end: string } {
return { start, end };
}
export async function GET(req: Request) {
/** @param {Request} req */
export async function GET(req) {
const session = await getServerSession();
if (session?.role !== "admin" && session?.role !== "cashier") {
return new Response("Forbidden", { status: 403 });
@@ -63,20 +68,28 @@ export async function GET(req: Request) {
const [{ data: custs }, { data: items }, { data: svcs }, { data: orders }] = await Promise.all([
custIds.length
? supabase.from("customers").select("id, last_name, first_name").in("id", custIds)
: Promise.resolve({ data: [] as { id: number; last_name: string; first_name: string }[] }),
: Promise.resolve({
data: /** @type {{ id: number, last_name: string, first_name: string }[]} */ ([]),
}),
ids.length
? supabase.from("order_items").select("checkup_id, line_total").in("checkup_id", ids)
: Promise.resolve({ data: [] as { checkup_id: number; line_total: number }[] }),
: Promise.resolve({
data: /** @type {{ checkup_id: number, line_total: number }[]} */ ([]),
}),
ids.length
? supabase.from("checkup_services").select("checkup_id, line_total").in("checkup_id", ids)
: Promise.resolve({ data: [] as { checkup_id: number; line_total: number }[] }),
: Promise.resolve({
data: /** @type {{ checkup_id: number, line_total: number }[]} */ ([]),
}),
ids.length
? supabase.from("medicine_orders").select("checkup_id, payment_status").in("checkup_id", ids)
: Promise.resolve({ data: [] as { checkup_id: number; payment_status: string }[] }),
: Promise.resolve({
data: /** @type {{ checkup_id: number, payment_status: string }[]} */ ([]),
}),
]);
const name = new Map((custs ?? []).map((c) => [c.id, `${c.last_name} ${c.first_name}`]));
const totalBy = new Map<number, number>();
const totalBy = /** @type {Map<number, number>} */ (new Map());
for (const it of [...(items ?? []), ...(svcs ?? [])]) {
totalBy.set(it.checkup_id, (totalBy.get(it.checkup_id) ?? 0) + it.line_total);
}
@@ -94,7 +107,7 @@ export async function GET(req: Request) {
const ws = XLSX.utils.json_to_sheet(sheetRows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, "Visits");
const buffer: Buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
const buffer = /** @type {Buffer} */ (XLSX.write(wb, { type: "buffer", bookType: "xlsx" }));
return new Response(new Uint8Array(buffer), {
headers: {
@@ -5,9 +5,10 @@
* Centers the card content on the page; no sidebar, no app nav.
* Deliberately does NOT call getServerSession() this is a public route.
*/
import type { ReactNode } from "react";
export default function AuthLayout({ children }: { children: ReactNode }) {
/**
* @param {{ children: import("react").ReactNode }} props
*/
export default function AuthLayout({ children }) {
return (
<div className="bg-background flex min-h-screen flex-col items-center justify-center px-4 py-12">
{/* Brand mark */}
@@ -20,7 +20,9 @@ import { getLocale, getTranslations } from "next-intl/server";
import { redirect } from "@/i18n/navigation";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { createRateLimiter } from "@/lib/upstash";
import { parseSignIn, type SignInState } from "@/lib/auth/schemas";
import { parseSignIn } from "@/lib/auth/schemas";
/** @typedef {import('@/lib/auth/schemas').SignInState} SignInState */
// Brute-force guard on the SHARED Supabase auth quota. Keyed by client IP
// (not email) so an attacker cannot lock a specific victim out. 5 tries / 60s.
@@ -40,10 +42,12 @@ const signInLimiter = createRateLimiter("login", 5, 60);
* out and return the same generic error (enumeration defense).
* 4. redirect to /${locale}/dashboard never returns on the success path.
*/
export async function signInAction(
_prevState: SignInState,
formData: FormData,
): Promise<SignInState> {
/**
* @param {SignInState} _prevState
* @param {FormData} formData
* @returns {Promise<SignInState>}
*/
export async function signInAction(_prevState, formData) {
const t = await getTranslations("auth.signIn");
// Step 1 — schema validation
@@ -160,7 +164,10 @@ export async function signInAction(
* The try/catch around signOut is intentional: cookie clearing is local, so
* even if the Supabase call fails we still redirect to clear the UI state.
*/
export async function signOutAction(): Promise<void> {
/**
* @returns {Promise<void>}
*/
export async function signOutAction() {
const supabase = await createSupabaseServerClient();
try {
@@ -9,11 +9,11 @@ import { redirect } from "@/i18n/navigation";
import { getServerSession } from "@/lib/auth/get-server-session";
import { SignInForm } from "./sign-in-form";
interface SignInPageProps {
params: Promise<{ locale: string }>;
}
export default async function SignInPage({ params }: SignInPageProps) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function SignInPage({ params }) {
const { locale } = await params;
// Redirect authenticated users away from the sign-in page.
@@ -23,25 +23,29 @@ import { Eye, EyeOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { SignInSchema, type SignInInput, type SignInState } from "@/lib/auth/schemas";
import { SignInSchema } from "@/lib/auth/schemas";
import { signInAction } from "./actions";
/** @typedef {import('@/lib/auth/schemas').SignInInput} SignInInput */
/** @typedef {import('@/lib/auth/schemas').SignInState} SignInState */
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/** Email/password sign-in form backed by a server action. @returns {import("react").JSX.Element} */
export function SignInForm() {
const t = useTranslations("auth.signIn");
// useActionState wires React 19's form action mechanism.
// isPending reflects the Transition wrapping the Server Action round-trip.
const [state, dispatchAction, isPending] = useActionState<SignInState, FormData>(signInAction, {
const [state, dispatchAction, isPending] = useActionState(signInAction, {
status: "idle",
});
// RHF: client-side Zod validation for instant inline feedback.
// mode:"onBlur" fires validation when the user leaves a field.
const form = useForm<SignInInput>({
const form = useForm({
resolver: zodResolver(SignInSchema),
mode: "onBlur",
defaultValues: { email: "", password: "" },
@@ -9,7 +9,10 @@
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
export default function LocaleError({ reset }: { error: Error; reset: () => void }) {
/**
* @param {{ error: Error, reset: () => void }} props
*/
export default function LocaleError({ reset }) {
const t = useTranslations("errors");
return (
<div className="mx-auto flex min-h-[60vh] max-w-md flex-col items-center justify-center px-4 text-center">
@@ -5,8 +5,6 @@ import { NextIntlClientProvider, hasLocale } from "next-intl";
import { setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import { Be_Vietnam_Pro } from "next/font/google";
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { routing } from "@/i18n/routing";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { SessionProvider } from "@/lib/auth/session-provider";
@@ -23,29 +21,30 @@ const beVietnamPro = Be_Vietnam_Pro({
display: "swap",
});
export const metadata: Metadata = {
/** @type {import("next").Metadata} */
export const metadata = {
title: "BSK Clinic",
description: "Educational clinic management rewrite",
};
export const viewport: Viewport = {
/** @type {import("next").Viewport} */
export const viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
],
};
/** Pre-renders one static shell per supported locale. @returns {{ locale: string }[]} */
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
/**
* @param {{ children: import("react").ReactNode, params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function LocaleLayout({ children, params }) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
@@ -1,5 +1,8 @@
import { getTranslations } from "next-intl/server";
/**
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function LocaleNotFound() {
const t = await getTranslations("errors");
@@ -1,6 +1,10 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
/**
* @param {{ params: Promise<{ locale: string }> }} props
* @returns {Promise<import("react").JSX.Element>}
*/
export default async function HomePage({ params }) {
const { locale } = await params;
// Required per next-intl static-rendering guide; intentionally duplicated
// alongside the same call in `[locale]/layout.tsx`. Do not remove.
@@ -17,7 +17,10 @@ export const dynamic = "force-dynamic";
const RETENTION_DAYS = 7;
const MEDIA_BUCKET = "bsk-checkup-media";
export async function GET(req: Request) {
/**
* @param {Request} req
*/
export async function GET(req) {
if (!serverEnv.CRON_SECRET) {
return new Response("CRON_SECRET not configured", { status: 503 });
}
+4 -3
View File
@@ -1,5 +1,3 @@
import type { ReactNode } from "react";
// Intentionally empty pass-through. The real `<html>` + `<body>` live in
// `app/[locale]/layout.tsx` so `lang={locale}` and the i18n provider can
// be set per locale. Do not put global UI here.
@@ -13,6 +11,9 @@ import type { ReactNode } from "react";
// render without `<html><body>`. Prefer placing `error.tsx` files under
// `app/[locale]/` (where the real shell lives), or render the document
// shell explicitly inside any root-level error file.
export default function RootLayout({ children }: { children: ReactNode }) {
/**
* @param {{ children: import("react").ReactNode }} props
*/
export default function RootLayout({ children }) {
return children;
}
+1
View File
@@ -3,6 +3,7 @@ import { routing } from "@/i18n/routing";
// Outside-locale fallback (URL didn't match any locale segment). Renders
// without locale context, so messages are hardcoded in both languages.
/** Root 404 page outside the locale segment. @returns {import("react").JSX.Element} */
export default function GlobalNotFound() {
return (
<html lang={routing.defaultLocale}>
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tsx": false,
"tailwind": {
"config": "",
"css": "app/globals.css",
@@ -13,7 +13,7 @@
* the layout. Drawer auto-closes on route change.
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState } from "react";
import { Menu } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname } from "@/i18n/navigation";
@@ -21,17 +21,19 @@ import { usePathname } from "@/i18n/navigation";
const DRAWER_ID = "app-mobile-drawer";
const MAIN_ID = "main-content";
type AppShellFrameProps = {
sidebar: ReactNode;
children: ReactNode;
};
/**
* @typedef {object} AppShellFrameProps
* @property {import("react").ReactNode} sidebar
* @property {import("react").ReactNode} children
*/
export function AppShellFrame({ sidebar, children }: AppShellFrameProps) {
/** @param {AppShellFrameProps} props */
export function AppShellFrame({ sidebar, children }) {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const t = useTranslations("app");
const drawerRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const drawerRef = useRef(/** @type {HTMLDivElement | null} */ (null));
const triggerRef = useRef(/** @type {HTMLButtonElement | null} */ (null));
// Close the drawer on route change (nav tap, redirect). Adjusting state
// during render on a changed value is the React-recommended pattern no
@@ -52,10 +54,12 @@ export function AppShellFrame({ sidebar, children }: AppShellFrameProps) {
if (!drawer) return;
const focusable = () =>
Array.from(
drawer.querySelectorAll<HTMLElement>(
'a[href],button:not([disabled]),select,input,textarea,[tabindex]:not([tabindex="-1"])',
),
/** @type {HTMLElement[]} */ (
Array.from(
drawer.querySelectorAll(
'a[href],button:not([disabled]),select,input,textarea,[tabindex]:not([tabindex="-1"])',
),
)
);
focusable()[0]?.focus();
@@ -64,7 +68,8 @@ export function AppShellFrame({ sidebar, children }: AppShellFrameProps) {
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
function onKeyDown(e: KeyboardEvent) {
/** @param {KeyboardEvent} e */
function onKeyDown(e) {
if (e.key === "Escape") {
setOpen(false);
return;
@@ -6,20 +6,20 @@
* user info from the (app) layout which has already validated session + role.
*/
import type { ReactNode } from "react";
import type { AppRole } from "@/lib/db/roles";
import { Sidebar } from "@/components/app-shell/sidebar";
import { AppShellFrame } from "@/components/app-shell/app-shell-frame";
type AppShellProps = {
email: string;
fullName: string | null;
role: AppRole;
locale: string;
children: ReactNode;
};
/**
* @typedef {object} AppShellProps
* @property {string} email
* @property {string | null} fullName
* @property {import("@/lib/db/roles").AppRole} role
* @property {string} locale
* @property {import("react").ReactNode} children
*/
export function AppShell({ email, fullName, role, locale, children }: AppShellProps) {
/** @param {AppShellProps} props */
export function AppShell({ email, fullName, role, locale, children }) {
return (
<AppShellFrame
sidebar={<Sidebar email={email} fullName={fullName} role={role} locale={locale} />}
@@ -13,6 +13,7 @@ import { useLocale, useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { routing } from "@/i18n/routing";
/** Dropdown that switches the active locale while preserving the path. @returns {import("react").JSX.Element} */
export function LocaleSwitcher() {
const locale = useLocale();
const router = useRouter();
@@ -22,8 +23,9 @@ export function LocaleSwitcher() {
// slots (desktop rail + mobile drawer) no duplicate id / label collision.
const selectId = useId();
function handleChange(e: React.ChangeEvent<HTMLSelectElement>) {
const nextLocale = e.target.value as (typeof routing.locales)[number];
/** @param {import("react").ChangeEvent<HTMLSelectElement>} e */
function handleChange(e) {
const nextLocale = /** @type {(typeof routing.locales)[number]} */ (e.target.value);
router.replace(pathname, { locale: nextLocale });
}
@@ -15,14 +15,15 @@
import { useTranslations } from "next-intl";
import { Link, usePathname } from "@/i18n/navigation";
import { ROLE_MENU } from "@/lib/auth/role-menu";
import type { AppRole } from "@/lib/db/roles";
type SidebarNavProps = {
role: AppRole;
locale: string;
};
/**
* @typedef {object} SidebarNavProps
* @property {import("@/lib/db/roles").AppRole} role
* @property {string} locale
*/
export function SidebarNav({ role, locale }: SidebarNavProps) {
/** @param {SidebarNavProps} props */
export function SidebarNav({ role, locale }) {
const t = useTranslations();
const pathname = usePathname();
const items = ROLE_MENU[role];
@@ -37,7 +38,7 @@ export function SidebarNav({ role, locale }: SidebarNavProps) {
<li key={item.href}>
<Link
href={item.href}
locale={locale as "vi" | "en"}
locale={/** @type {"vi" | "en"} */ (locale)}
aria-current={isActive ? "page" : undefined}
className={`flex min-h-11 items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors ${
isActive
@@ -46,7 +47,7 @@ export function SidebarNav({ role, locale }: SidebarNavProps) {
}`}
>
<Icon className="size-4 shrink-0" />
{t(item.labelKey as Parameters<typeof t>[0])}
{t(/** @type {Parameters<typeof t>[0]} */ (item.labelKey))}
</Link>
</li>
);
@@ -7,19 +7,20 @@
*/
import { getTranslations } from "next-intl/server";
import type { AppRole } from "@/lib/db/roles";
import { SidebarNav } from "@/components/app-shell/sidebar-nav";
import { SignOutButton } from "@/components/app-shell/sign-out-button";
import { LocaleSwitcher } from "@/components/app-shell/locale-switcher";
type SidebarProps = {
email: string;
fullName: string | null;
role: AppRole;
locale: string;
};
/**
* @typedef {object} SidebarProps
* @property {string} email
* @property {string | null} fullName
* @property {import("@/lib/db/roles").AppRole} role
* @property {string} locale
*/
export async function Sidebar({ email, fullName, role, locale }: SidebarProps) {
/** @param {SidebarProps} props */
export async function Sidebar({ email, fullName, role, locale }) {
const tRoles = await getTranslations("roles");
return (
@@ -26,6 +26,7 @@ function SignOutButtonInner() {
);
}
/** Signs the user out via server action and redirects to sign-in. @returns {import("react").JSX.Element} */
export function SignOutButton() {
return (
<form action={signOutAction}>
@@ -1,5 +1,4 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
@@ -25,12 +24,12 @@ const badgeVariants = cva(
},
);
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
/**
* @param {import("react").ComponentProps<"span"> &
* import("class-variance-authority").VariantProps<typeof badgeVariants> &
* { asChild?: boolean }} props
*/
function Badge({ className, variant = "default", asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "span";
return (
@@ -1,5 +1,4 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
@@ -36,16 +35,12 @@ const buttonVariants = cva(
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
/**
* @param {import("react").ComponentProps<"button"> &
* import("class-variance-authority").VariantProps<typeof buttonVariants> &
* { asChild?: boolean }} props
*/
function Button({ className, variant = "default", size = "default", asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button";
return (
@@ -1,8 +1,7 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
/** @param {import("react").ComponentProps<"input">} props */
function Input({ className, type, ...props }) {
return (
<input
type={type}
@@ -1,11 +1,11 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
/** @param {import("react").ComponentProps<typeof LabelPrimitive.Root>} props */
function Label({ className, ...props }) {
return (
<LabelPrimitive.Root
data-slot="label"
@@ -1,16 +1,11 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
/** @param {import("react").ComponentProps<typeof SeparatorPrimitive.Root>} props */
function Separator({ className, orientation = "horizontal", decorative = true, ...props }) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
@@ -8,14 +8,15 @@ import {
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { Toaster as Sonner } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
/** @param {import("sonner").ToasterProps} props */
const Toaster = ({ ...props }) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={/** @type {import("sonner").ToasterProps["theme"]} */ (theme)}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
@@ -25,12 +26,12 @@ const Toaster = ({ ...props }: ToasterProps) => {
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
/** @type {import("react").CSSProperties} */ ({
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
})
}
{...props}
/>
+1 -1
View File
@@ -120,5 +120,5 @@ Channel names live in a single project-wide namespace. Prefix every channel with
Free-tier PITR is project-wide; a restore wipes every app's data to a point in time. Per-app rollback is not supported by Supabase. Mitigations:
- Daily `pg_dump --schema=bsk` cron (see `docs/runbooks/restore-from-bad-migration.md`).
- Preflight check on migrations (see `scripts/preflight-supabase.ts`).
- Preflight check on migrations (see `scripts/preflight-supabase.mjs`).
- Never run destructive DDL without a recent dump.
+40 -14
View File
@@ -1,18 +1,25 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypeScript from "eslint-config-next/typescript";
import prettier from "eslint-config-prettier";
import jsdoc from "eslint-plugin-jsdoc";
const config = [
{ ignores: [".next/**", "node_modules/**", "dist/**", "out/**", ".vercel/**"] },
{
ignores: [
".next/**",
"node_modules/**",
"dist/**",
"out/**",
".vercel/**",
"coverage/**",
"playwright-report/**",
"test-results/**",
],
},
...nextCoreWebVitals,
...nextTypeScript,
prettier,
{
rules: {
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
// Force every call site through the prefixed / schema-scoped factories.
// Raw clients bypass the bsk:{env}: Redis prefix and the schema='bsk'
// scoping, which collide with sibling apps sharing the same project.
@@ -40,29 +47,48 @@ const config = [
],
},
},
{
files: ["**/*.{js,jsx,mjs,cjs}"],
plugins: { jsdoc },
rules: {
"jsdoc/require-jsdoc": [
"warn",
{
publicOnly: true,
require: {
FunctionDeclaration: true,
FunctionExpression: true,
ArrowFunctionExpression: true,
},
},
],
"jsdoc/require-param-type": "warn",
"jsdoc/require-returns-type": "warn",
},
},
// Only the named factory files may import the raw infrastructure libs.
// Explicit filenames (not a glob) keep the trust boundary tight — adding a
// new factory should be a deliberate PR change here, not an accidental
// file landing under `lib/supabase/*`.
{
files: [
"lib/upstash.ts",
"lib/supabase/server.ts",
"lib/supabase/client.ts",
"lib/supabase/admin.ts",
"lib/supabase/session.ts",
"lib/upstash.js",
"lib/supabase/server.js",
"lib/supabase/client.js",
"lib/supabase/admin.js",
"lib/supabase/session.js",
],
rules: { "no-restricted-imports": "off" },
},
// Standalone Node scripts (seed/preflight) run outside Next and legitimately
// build their own schema-scoped client from env — not the request factories.
{
files: ["scripts/**/*.ts", "scripts/**/*.mjs"],
files: ["scripts/**/*.js", "scripts/**/*.mjs"],
rules: { "no-restricted-imports": "off" },
},
// Test files (unit/e2e) and config files are exempt from Next.js rules.
{
files: ["tests/**/*.ts", "*.config.ts", "*.config.mjs"],
files: ["tests/**/*.js", "*.config.js", "*.config.mjs"],
rules: {
"@next/next/no-html-link-for-pages": "off",
},
View File
+2 -2
View File
@@ -1,9 +1,9 @@
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["vi", "en"] as const,
locales: /** @type {const} */ (["vi", "en"]),
defaultLocale: "vi",
localePrefix: "as-needed",
});
export type Locale = (typeof routing.locales)[number];
/** @typedef {(typeof routing.locales)[number]} Locale */
+13 -10
View File
@@ -2,7 +2,8 @@
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
@@ -18,7 +19,6 @@
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
},
@@ -26,12 +26,15 @@
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules", ".next/cache", ".next/dev/cache"]
"include": ["next-env.d.ts", "**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs", "**/*.d.ts"],
"exclude": [
"node_modules",
".next",
"out",
"dist",
".vercel",
"coverage",
"playwright-report",
"test-results"
]
}
@@ -1,20 +1,22 @@
import "server-only";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import { isAppRole, type AppRole } from "@/lib/db/roles";
import { isAppRole } from "@/lib/db/roles";
/** @typedef {import("@/lib/db/roles").AppRole} AppRole */
// Derive the User type from the factory's return type so we never import
// @supabase/supabase-js directly (ESLint no-restricted-imports enforces that
// only the named factory files in lib/supabase/* may do so).
type SupabaseServerClient = Awaited<ReturnType<typeof createSupabaseServerClient>>;
type GetUserResult = Awaited<ReturnType<SupabaseServerClient["auth"]["getUser"]>>;
export type User = NonNullable<GetUserResult["data"]["user"]>;
/** @typedef {Awaited<ReturnType<typeof createSupabaseServerClient>>} SupabaseServerClient */
/** @typedef {Awaited<ReturnType<SupabaseServerClient["auth"]["getUser"]>>} GetUserResult */
/** @typedef {NonNullable<GetUserResult["data"]["user"]>} User */
export type ServerSession = {
user: User;
role: AppRole | null;
/** Display name from bsk.app_users.full_name; null until an admin sets it. */
fullName: string | null;
};
/**
* @typedef {object} ServerSession
* @property {User} user
* @property {AppRole | null} role
* @property {string | null} fullName Display name from bsk.app_users.full_name; null until an admin sets it.
*/
/**
* Reads the authenticated user and their BSK role from the current request.
@@ -31,9 +33,12 @@ export type ServerSession = {
*
* Used by: `[locale]/layout.tsx` (phase 02 establishes the pattern),
* protected route layouts (phase 06), and Server Actions that need role checks.
*
* @returns {Promise<ServerSession | null>}
*/
export async function getServerSession(): Promise<ServerSession | null> {
let supabase: SupabaseServerClient;
export async function getServerSession() {
/** @type {SupabaseServerClient} */
let supabase;
try {
supabase = await createSupabaseServerClient();
@@ -59,8 +64,10 @@ export async function getServerSession(): Promise<ServerSession | null> {
// own role — and folds the former separate full_name lookup into the same
// round-trip. Pre-provisioning (table absent) or transient DB errors fall
// back to role/fullName = null, never an auth failure.
let role: AppRole | null = null;
let fullName: string | null = null;
/** @type {AppRole | null} */
let role = null;
/** @type {string | null} */
let fullName = null;
try {
const { data: profile } = await supabase
@@ -17,24 +17,26 @@ export const InviteUserSchema = z.object({
role: z.enum(appRoles),
});
export type InviteUserInput = z.infer<typeof InviteUserSchema>;
/** @typedef {import("zod").infer<typeof InviteUserSchema>} InviteUserInput */
// ---------------------------------------------------------------------------
// Discriminated-union state — returned by inviteUserAction, consumed by
// useActionState. All variants must be JSON-serializable.
// ---------------------------------------------------------------------------
export type InviteUserState =
| { status: "idle" }
| {
status: "error";
/** Per-field validation errors keyed by field name. */
fieldErrors: Record<string, string[]>;
/** Non-field error (forbidden, email taken, server error). Null when fieldErrors are set. */
formError: string | null;
}
| {
status: "success";
/** The email address of the newly invited user. */
invitedEmail: string;
};
/**
* @typedef {object} InviteUserErrorState
* @property {"error"} status
* @property {Record<string, string[]>} fieldErrors Per-field validation errors keyed by field name.
* @property {string | null} formError Non-field error (forbidden, email taken, server error). Null when fieldErrors are set.
*/
/**
* @typedef {object} InviteUserSuccessState
* @property {"success"} status
* @property {string} invitedEmail The email address of the newly invited user.
*/
/**
* @typedef {{ status: "idle" } | InviteUserErrorState | InviteUserSuccessState} InviteUserState
*/
@@ -18,13 +18,16 @@ import "server-only";
import { redirect } from "@/i18n/navigation";
import { getServerSession } from "@/lib/auth/get-server-session";
import type { AppRole } from "@/lib/db/roles";
import type { ServerSession } from "@/lib/auth/get-server-session";
export async function requireRole(
allowed: AppRole[],
locale: string,
): Promise<ServerSession & { role: AppRole }> {
/** @typedef {import("@/lib/db/roles").AppRole} AppRole */
/** @typedef {import("@/lib/auth/get-server-session").ServerSession} ServerSession */
/**
* @param {AppRole[]} allowed
* @param {string} locale
* @returns {Promise<ServerSession & { role: AppRole }>}
*/
export async function requireRole(allowed, locale) {
const session = await getServerSession();
if (!session?.user) {
@@ -38,5 +41,5 @@ export async function requireRole(
throw new Error("unreachable");
}
return session as ServerSession & { role: AppRole };
return /** @type {ServerSession & { role: AppRole }} */ (session);
}

Some files were not shown because too many files have changed in this diff Show More