mirror of
https://github.com/tiennm99/bsk.git
synced 2026-09-02 02:19:03 +00:00
Merge pull request #4 from tiennm99/ts-to-js-migration
refactor: migrate from TypeScript to JavaScript with JSDoc types
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
+20
-8
@@ -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;
|
||||
|
||||
+9
-6
@@ -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: "" },
|
||||
+5
-1
@@ -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");
|
||||
|
||||
+11
-7
@@ -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" ||
|
||||
+10
-11
@@ -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'.
|
||||
+5
-5
@@ -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);
|
||||
+31
-20
@@ -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;
|
||||
|
||||
+27
-21
@@ -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
|
||||
+5
-1
@@ -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 (
|
||||
+12
-4
@@ -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>
|
||||
+5
-1
@@ -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");
|
||||
|
||||
+20
-8
@@ -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;
|
||||
|
||||
+7
-8
@@ -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();
|
||||
+5
-1
@@ -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");
|
||||
|
||||
+12
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
+11
-12
@@ -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,
|
||||
+5
-5
@@ -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");
|
||||
|
||||
+10
-2
@@ -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");
|
||||
+5
-5
@@ -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);
|
||||
+31
-21
@@ -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;
|
||||
|
||||
+5
-1
@@ -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
-3
@@ -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>
|
||||
+23
-20
@@ -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]}
|
||||
+20
-13
@@ -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();
|
||||
+5
-2
@@ -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(() => {
|
||||
+35
-17
@@ -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;
|
||||
+9
-5
@@ -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 () => {
|
||||
+5
-5
@@ -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");
|
||||
+12
-7
@@ -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,
|
||||
+20
-11
@@ -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>
|
||||
+32
-18
@@ -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();
|
||||
+5
-5
@@ -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");
|
||||
+10
-9
@@ -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 ?? "—",
|
||||
+66
-57
@@ -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),
|
||||
+28
-21
@@ -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;
|
||||
|
||||
+53
-41
@@ -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
-3
@@ -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);
|
||||
}
|
||||
+2
-1
@@ -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">
|
||||
+5
-5
@@ -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);
|
||||
+10
-9
@@ -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>
|
||||
+32
-31
@@ -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;
|
||||
|
||||
+8
-14
@@ -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();
|
||||
+7
-6
@@ -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>
|
||||
+4
-3
@@ -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();
|
||||
+11
-18
@@ -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");
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
+10
-9
@@ -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(),
|
||||
+27
-14
@@ -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: {
|
||||
+21
-8
@@ -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.
|
||||
+7
-3
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+18
-13
@@ -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}
|
||||
/>
|
||||
@@ -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
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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 */
|
||||
@@ -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
Reference in New Issue
Block a user