# Sabana Family Portal — Next.js Conversion Guide

This app is a full rewrite of the Sabana Family Portal (previously an
Express + MySQL API serving a hand-rolled multi-page HTML/vanilla-JS
front-end) onto **Next.js App Router** — one unified app, no separate
Express server. This is a **faithful port**: same features, same data, same
visual design (we reuse the old `css/style.css` verbatim), same URL-shaped
information architecture — just rebuilt on React + Next.js Route Handlers.

The old source lives at `/home/claude/sabana_backend` (Express API) and
`/home/claude/sabana_family_portal` (HTML/JS front-end) — **read the
relevant old files before porting anything**; they are the spec. The old
`/home/claude/sabana_backend/API_REFERENCE.md` documents every endpoint's
exact request/response shape — those shapes are NOT changing, only the
server implementation technology is. The live MySQL database
(`sabana_portal`, user `sabana_app` / password `SabanaApp2026!`) is
unchanged and already has all the real data — nothing to re-seed.

This new app lives at `/home/claude/sabana_nextjs`. A dev server is
typically already running on `http://localhost:3000` — check
`curl -s http://localhost:3000/api/health` (should return
`{"ok":true,"db":"connected"}`); if it's not running, start it yourself:
`cd /home/claude/sabana_nextjs && nohup npm run dev -- -p 3000 > /tmp/nextdev.log 2>&1 < /dev/null & disown`
then wait a few seconds and re-check. Next.js's dev server hot-reloads on
file changes — you don't need to restart it after editing files, only if it
crashes or you change `next.config.js`/`package.json`.

## What's already built (the foundation) — read these before writing anything

- `lib/db.js` — MySQL pool (mysql2/promise), same credentials as the old
  `db.js`, cached on `globalThis` to survive Next.js dev hot-reload.
- `lib/session.js` + `lib/auth.js` — session/auth. Session rows live in the
  same MySQL `sessions` table, cookie name `sabana_sid`, httpOnly, 7-day
  expiry. **Every API route handler that needs a signed-in member starts
  with:**
  ```js
  import { requireAuth } from "../../../lib/auth"; // adjust relative depth
  export async function GET(request) {
    const { member, error } = await requireAuth();
    if (error) return error;
    // member is the raw `members` table row (snake_case columns) — same
    // shape as the old Express `req.member`.
    ...
  }
  ```
  For Principal/Admin-only routes, also call
  `const forbidden = requirePrincipalOrAdmin(member); if (forbidden) return forbidden;`
  (or `requirePrincipal`). These mirror the old `middleware/auth.js`
  `requireAuth`/`requirePrincipalOrAdmin`/`requirePrincipal` EXACTLY —
  same 401/403 semantics. **Module access (hr/investment/projects/finance/
  contacts/correspondence) is enforced CLIENT-SIDE ONLY, same as the old
  app** (confirmed, portal-wide, deliberate design choice found during the
  earlier QA pass) — do not add server-side module checks that don't exist
  in the old `routes/*.js` files.
- `app/api/auth/**` — fully ported reference example of Route Handlers
  (login, logout, me, change-password, member reset-password,
  reset-all-passwords). Look at these for the exact pattern: one
  `route.js` per URL segment, `export async function GET/POST/PUT/DELETE`,
  dynamic segments as `[id]/route.js` folders, `params` is a Promise in
  this Next.js version — `const { id } = await params;`.
- `lib/apiClient.js` — the fetch wrapper every CLIENT component (and every
  `lib/<module>.js` data-layer file) uses. **Its contract is deliberately
  identical to the old `js/data-core.js`'s `api()`/`j()` helpers**: `api(path,
  opts)` where `path` is relative to `/api` (e.g. `"/hr/employees"`),
  `opts.body` must be a PRE-STRINGIFIED JSON string via the `j()` helper
  (not a raw object — `api("/hr/employees", { method: "POST", body:
  j({...}) })`), and a failed call throws an `Error` with `.status` and
  `.data` (the parsed error JSON) — exactly like the old code, so the old
  `js/data-*.js` files port with NO changes to their internal call sites,
  only their module-wrapper syntax. See `lib/core.js` for a complete,
  already-ported example (old `js/data-core.js` → new, side by side, only
  the IIFE/`window.SabanaCore` wrapper changed to an ES module export).
- `lib/i18n/` — the full bilingual (EN/ID) dictionary, already merged from
  every old `i18n*.js` file (1527 keys, verified). Use it as:
  ```js
  import { t, tn, getLang, setLang } from "../../lib/i18n"; // adjust depth
  t("hr.field.nik")            // → "NIK" / "NIK" (falls back to the key itself if missing)
  t("dashboard.welcome", { name: user.name })   // {vars} interpolation
  tn(n, "myprofile.attendance_sub", "myprofile.attendance_sub_plural")  // singular/plural
  ```
  **Hydration-safety rule — this is important, read it twice:** `t()` and
  `getLang()` read `localStorage`, which doesn't exist during server
  rendering. React requires a Client Component's very first render (before
  any `useEffect` runs) to produce IDENTICAL output on the server and on
  the client's first pass, or you get a hydration-mismatch error/warning.
  **So: never call `t()`/`getLang()` in a component's render path until
  AFTER that component is only rendering post-mount / post-data-ready.**
  The house pattern (see `app/page.js` and `components/layout/LoadingShell.jsx`
  for reference):
  ```jsx
  "use client";
  export default function SomePage() {
    const { ready } = useRequireAuth("hr"); // or no arg for a page open to anyone signed in
    if (!ready) return <LoadingShell />;    // plain, untranslated, identical every time
    return <AppShell title={t("hr.employees.title")}>...</AppShell>; // t() only from here down
  }
  ```
  Because `ready` starts `false` on both server and client (the auth check
  is itself async, resolved in an effect), the LoadingShell is what
  actually gets hydrated, and the real translated content only ever
  renders client-side afterward — no mismatch possible. Every page MUST
  follow this shape. (The one exception is the login page itself, which
  gates on a plain `mounted` flag instead of `ready` since it has no auth
  check to wait on — see `app/page.js`.)
- `contexts/AuthContext.jsx` — `useAuth()` gives `{user, loading, login,
  logout, refresh, hasModuleAccess}`; `useRequireAuth(moduleKey?)` is what
  every protected page calls (see above) — it also handles the
  `sabana_family_access_denied` redirect-to-dashboard-with-toast behavior
  from the old `app.js` guard, for a page whose module the signed-in member
  lacks. Pages with no module restriction (dashboard, settings, my-profile,
  group-of-companies, entity-detail) call `useRequireAuth()` with no
  argument.
- `contexts/ToastContext.jsx` — `useToast().show("message")`, port of the
  old `Sabana.toast()`/`showToast()`.
- `components/layout/AppShell.jsx` — the sidebar + topbar + mobile nav +
  language toggle + sign-out, port of `app.js`'s `renderSidebar`/
  `applyIdentity`/`initLangToggle`/`initMobileNav`/`initSignOut`. Use it as
  the outer wrapper for every protected page's real content:
  ```jsx
  return (
    <AppShell title={t("hr.employees.title")} subtitle={t("hr.employees.subtitle")}>
      {/* page content */}
    </AppShell>
  );
  ```
- `components/layout/LoadingShell.jsx` — the plain pre-ready state (see
  hydration rule above).
- `lib/nav.js` — the sidebar nav config (already updated for the new
  route paths below).
- Static assets: `public/css/style.css` (the ORIGINAL stylesheet, byte for
  byte — reuse its existing class names in your JSX exactly as the old
  HTML used them; do not invent new CSS unless a genuinely new UI pattern
  needs it, and if so add it to this same file), `public/assets/*.png`
  (logos/favicons), `public/js/vendor/*.js` (jspdf, leaflet, qrcode — for
  pages that need them, e.g. `hr/checkin-qr.html`'s QR code, load via a
  plain `<script src="/js/vendor/qrcode.js">` tag or dynamic `next/script`,
  whichever is simpler for that page's use case).

## Route map (old file → new Next.js route)

Dynamic detail pages that used to take an `?id=` query string are now
clean path segments (`[id]`) — a deliberate, low-risk modernization since
it's purely a URL-shape change with zero effect on features or UI.

| Old file | New route |
|---|---|
| `index.html` | `/` (done) |
| `dashboard.html` | `/dashboard` |
| `my-profile.html` | `/my-profile` |
| `settings.html` | `/settings` |
| `group-of-companies.html` | `/group-of-companies` |
| `entity-detail.html?id=` | `/entity-detail/[id]` |
| `contacts/contacts.html` | `/contacts` |
| `correspondence/correspondence.html` | `/correspondence` |
| `correspondence/correspondence-detail.html?id=` | `/correspondence/[id]` |
| `hr/employees.html` | `/hr/employees` |
| `hr/employee-detail.html?id=` | `/hr/employees/[id]` |
| `hr/add-employee.html` (create) | `/hr/employees/add` |
| `hr/add-employee.html?id=` (edit — same form, reused) | `/hr/employees/[id]/edit` |
| `hr/organization.html` | `/hr/organization` |
| `hr/job-descriptions.html` | `/hr/job-descriptions` |
| `hr/payroll.html` | `/hr/payroll` |
| `hr/payslip.html?id=` | `/hr/payslip/[id]` |
| `hr/attendance.html` | `/hr/attendance` |
| `hr/leave.html` | `/hr/leave` |
| `hr/business-card.html?id=` | `/hr/business-card/[id]` |
| `hr/letterhead.html` | `/hr/letterhead` |
| `hr/org-chart-print.html` | `/hr/org-chart-print` |
| `hr/checkin.html` | `/hr/checkin` |
| `hr/checkin-qr.html` | `/hr/checkin-qr` |
| `investment/portfolio.html` | `/investment/portfolio` |
| `investment/transactions.html` | `/investment/transactions` |
| `investment/reports.html` | `/investment/reports` |
| `projects/projects.html` | `/projects` |
| `projects/project.html?id=` | `/projects/[id]` |
| `projects/finance.html` | `/projects/finance` |
| `projects/tasks.html` | `/projects/tasks` |
| `projects/team.html` | `/projects/team` |
| `projects/calendar.html` | `/projects/calendar` |
| `projects/docs.html` | `/projects/docs` |
| `projects/wiki.html` | `/projects/wiki` |
| `projects/wiki-article.html?id=` | `/projects/wiki/[id]` |
| `finance/chart-of-accounts.html` | `/finance/chart-of-accounts` |
| `finance/ledger.html` | `/finance/ledger` |
| `finance/statements.html` | `/finance/statements` |
| `finance/budgets.html` | `/finance/budgets` |

Every route above needs a `page.js` under `app/<route>/` (e.g.
`app/hr/employees/page.js`, `app/hr/employees/[id]/page.js`). All are
Client Components (`"use client"` at the top) — this whole app is
client-rendered after the loading gate, there is no server-rendered data
fetching in this port (keeps the conversion mechanical and low-risk; see
the hydration rule above for why).

## API route handler conventions

- One Express `routes/<name>.js` file → one `app/api/<name>/` tree of
  `route.js` files, one per URL shape. Express `router.get("/foo/:id/bar",
  ...)` becomes `app/api/<name>/foo/[id]/bar/route.js` exporting `async
  function GET(request, { params })`.
- Multiple HTTP verbs on the same path go in the SAME `route.js` file as
  separate exports (`export async function GET`, `export async function
  POST`, etc.) — Next.js dispatches by method automatically.
- Read the JSON body with `const body = await request.json().catch(() =>
  ({}));` (Express's `req.body` becomes this one line at the top of a
  POST/PUT handler).
- Read query params with `const { searchParams } = new URL(request.url);
  searchParams.get("from")` (Express's `req.query.from`).
- Every DB query is IDENTICAL SQL to the old route — copy it verbatim,
  including any helper functions the old file defined (e.g. `mapEntity`,
  `computeAmount`, rate-fetching helpers) — port those as plain functions
  in the same route file or a small shared helper module if several routes
  need one (e.g. `lib/financeHelpers.js` for the Finance module's account/
  rate helpers, `lib/pmHelpers.js` for Projects' activity logging).
- Response shape: `return NextResponse.json(data)` for 200 (default
  status), `return NextResponse.json(data, { status: 201 })` /
  `{ status: 409 }` / etc. for anything else — match the old route's exact
  status codes and JSON error shapes (`{error: "..."}` or `{ok: false,
  reason: "..."}`, whichever that route used) since the ported
  `lib/<module>.js` client code (see below) expects them unchanged.
- The Finance module's routes are all nested under `/api/finance/:entityId/...`
  in the old app EXCEPT the three `consolidated/...` ones, which must NOT
  be nested under an `[entityId]` dynamic segment — keep
  `app/api/finance/consolidated/income-statement/route.js` etc as sibling,
  static routes next to `app/api/finance/[entityId]/...`. (Next.js resolves
  static segments before dynamic ones at the same level, same as Express's
  route-order issue the old backend once had and fixed — but in Next.js
  there's no ordering footgun since folder structure makes them genuinely
  separate routes; just don't nest `consolidated` under `[entityId]`.)

## Client data-layer (`lib/<module>.js`) conventions

Port each old `js/data-<module>.js` into `lib/<module>.js` as an ES module
exporting a plain object (e.g. `export const SabanaHR = {...}`), following
`lib/core.js` as the worked example: same function names, same internal
logic, same async/sync split (pure formatting/computation helpers that
don't hit the network stay synchronous — e.g. HR's `attendanceSummary`,
`weeklyAttendanceRate`, `departmentCounts`, `payrollSummary`,
`salaryBreakdown`, CSV parsing helpers; anything hitting `api()` is async).
Only the wrapper syntax changes:
- `(function () { "use strict"; var api = window.SabanaCore.api; ... window.SabanaHR = {...}; })();`
  →
  ```js
  "use client";
  import { api, j } from "./apiClient";
  import { SabanaCore } from "./core"; // for the "point at SabanaCore" delegated methods
  export const SabanaHR = { ... };
  ```
- Where the old file did
  `["getTeam","getAllMembers",...].forEach(k => { window.SabanaHR[k] = window.SabanaCore[k].bind(window.SabanaCore); })`
  at the bottom (delegating shared Core methods onto the module object),
  just spread them directly in the object literal instead:
  `export const SabanaHR = { ...someOwnMethods, getTeam: SabanaCore.getTeam, getAllMembers: SabanaCore.getAllMembers, ... };`
  (no need to replicate the dynamic `.forEach`/`.bind` — a plain literal is
  equivalent and clearer).
- Every page component imports what it needs directly:
  `import { SabanaHR } from "../../lib/hr";` (adjust relative depth) and
  calls `await SabanaHR.getEmployees()` etc, same as the old page's
  `await SabanaHR.getEmployees()` — page-level call sites barely change.

## Page component conventions

- Every `page.js` is a Client Component. Shape:
  ```jsx
  "use client";
  import { useEffect, useState } from "react";
  import { useRequireAuth } from "../../contexts/AuthContext";
  import AppShell from "../../components/layout/AppShell";
  import LoadingShell from "../../components/layout/LoadingShell";
  import { t } from "../../lib/i18n";
  import { SabanaHR } from "../../lib/hr";

  export default function EmployeesPage() {
    const { ready } = useRequireAuth("hr");
    const [employees, setEmployees] = useState(null);

    useEffect(() => {
      if (!ready) return;
      SabanaHR.getEmployees().then(setEmployees);
    }, [ready]);

    if (!ready || employees === null) return <LoadingShell />;

    return (
      <AppShell title={t("hr.employees.title")} subtitle={t("hr.employees.subtitle")}>
        {/* ...JSX built from `employees`, using the SAME class names as the
            old page's innerHTML-string-building version — read the old
            page's <script> block to see exactly what it rendered and copy
            its logic into JSX/state instead of string concatenation. */}
      </AppShell>
    );
  }
  ```
- The old pages built markup via string concatenation into `.innerHTML`
  (e.g. `document.getElementById('employeeList').innerHTML = rows.map(...).join('')`).
  Port this to real JSX with `.map()` returning an array of elements (with
  a `key` prop), React state for anything interactive (modals, form
  fields, filters), and event handlers as real `onClick`/`onChange`/
  `onSubmit` props instead of `addEventListener`. The RENDER LOGIC and
  DATA SHAPES stay the same — only the mechanism for producing DOM changes.
- Server member/employee/contact records have NO `initials` field (unlike
  very old localStorage-era shapes) — several old pages already defined a
  local `initialsFor(person)` fallback (derive from `.name`); port that
  same helper wherever an avatar is rendered. `AppShell.jsx` already has
  one for the sidebar/topbar identity chip — copy that implementation.
- For a page previously reached via `target="_blank"` (e.g. an "Export"
  link) — Next.js client-side routing handles same-tab navigation fine, and
  there's no more sessionStorage-cache-timing issue to worry about (see
  `contexts/AuthContext.jsx`'s doc comment) — a plain `<Link>` or `<a
  target="_blank">` both work correctly now.
- Currency/number formatting, date math, markdown rendering, CSV
  import/export, base64 file handling, QR code generation, PDF export
  (jsPDF) — port the exact same logic/library calls the old page used; the
  vendor libs are already copied into `public/js/vendor/`.

## Testing requirement (mandatory for every module)

After porting a module's API routes + `lib/<module>.js`: verify with
direct `curl`/a small Node script against `http://localhost:3000/api/...`
(log in first to get a session cookie, reuse it) — compare responses
against what the OLD Express server would have returned for the same data
(the shapes are documented exactly in `API_REFERENCE.md`), and spot-check
a few values against `mysql -u sabana_app -pSabanaApp2026! sabana_portal
-e "SELECT ..."` directly, the same rigor used throughout this project's
earlier QA passes.

After porting a module's pages: use Playwright
(`chromium.launch({executablePath: '/opt/pw-browsers/chromium'})`) against
`http://localhost:3000`, log in as `ridha@sabana.me` / `sabana2026`
(Principal, has every module), visit each new page, and confirm ZERO
`console.error`/`pageerror` events — a real hydration mismatch shows up
here as a console error, which is exactly why the loading-gate pattern
above matters. Exercise at least one write action per page (create/edit/
delete something), verify it landed correctly in MySQL, then clean up
back to the original state, exactly like the earlier QA passes on the
Express version did.

You have full authority to fix anything you find broken in ANY file —
don't just report an issue, fix it and re-verify.
