"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { usePathname, useRouter } from "next/navigation"; import Link from "next/link"; import { getApiVersion, logout, me, updateUiScale, type ServiceVersion } from "@/lib/api"; import { webBuildInfo } from "@/lib/build-info"; import { AuthContext, can } from "@/lib/abilities"; import { ROLE_LABEL } from "@/lib/labels"; import { DEFAULT_UI_SCALE, applyUiScale, normalizeUiScale, readUiScale, saveUiScale, } from "@/lib/ui-scale"; import { FontScaleControl } from "./FontScaleControl"; import type { AuthUser, Ability } from "@/lib/types"; /** * Authenticated shell: gates on /auth/me, redirects to /login when the * session is missing, renders the brand header + logout, and wraps page * content. Provides the AuthContext so any page can read the user's * abilities. Used by every authenticated page. */ type NavLink = { href: string; label: string; ability?: Ability; /** Shown when the user holds *any* of these — for a screen that merges two * separately-gated jobs (Notificaciones: servicios + pólizas). */ anyAbility?: Ability[]; exact?: boolean; /** Extra path prefixes that belong to this entry (e.g. a second route into * the same screen), so they highlight it instead of nothing. */ aliases?: string[]; }; type NavEntry = | ({ kind: "link" } & NavLink) | { kind: "group"; label: string; items: NavLink[] }; /** * Top nav. Daily screens stay one click away; the movement screens and the * admin screens are grouped behind menus so the bar doesn't saturate as the * app grows. A group disappears entirely when the user can't see any of its * items (gating here is cosmetic — the API enforces every write). */ const NAV: NavEntry[] = [ { kind: "link", href: "/inicio", label: "Inicio", exact: true }, { kind: "link", href: "/clientes", label: "Clientes" }, { kind: "link", href: "/polizas", label: "Pólizas" }, { kind: "link", href: "/servicios", label: "Propiedades" }, { kind: "group", label: "Cobranza", items: [ // Daily data-entry screen (the legacy "Editor"). Hidden from VIEWER, who // can't capture anyway — the page itself also refuses. Both capture modes // live behind this one entry: keying receipts by hand, and scanning a // stack of bills for OCR (the `/recibos` route opens the same screen on // its automatic tab). { href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create", aliases: ["/recibos"], }, { href: "/estado-cuenta", label: "Estado de cuenta" }, { href: "/banco", label: "Chequera" }, ], }, { kind: "link", href: "/reportes", label: "Reportes" }, { kind: "group", label: "Admin", items: [ { href: "/catalogos", label: "Catálogos", ability: "lookup:manage" }, { href: "/banco/cuentas", label: "Cuentas de chequera", ability: "bank:manage-accounts", }, // Mass email (servicios) and renewal notices (pólizas) are two tabs of // one screen; `/renovaciones` opens the same page on its pólizas tab. { href: "/notificaciones", label: "Notificaciones", anyAbility: ["notification:send", "renewal:send"], aliases: ["/renovaciones"], }, { href: "/usuarios", label: "Usuarios", ability: "user:manage" }, { href: "/operaciones", label: "Operaciones", ability: "db:manage" }, ], }, ]; /** Every nav destination, flattened out of the groups. */ const NAV_LINKS: NavLink[] = NAV.flatMap((entry) => entry.kind === "link" ? [entry] : entry.items, ); /** The nav the given user may see, with empty groups dropped. */ function visibleNav(user: AuthUser | null): NavEntry[] { const allowed = (item: NavLink) => (!item.ability || can(user, item.ability)) && (!item.anyAbility || item.anyAbility.some((a) => can(user, a))); const out: NavEntry[] = []; for (const entry of NAV) { if (entry.kind === "link") { if (allowed(entry)) out.push(entry); continue; } const items = entry.items.filter(allowed); if (items.length > 0) out.push({ ...entry, items }); } return out; } /** * Which nav entry is highlighted for a path. Longest matching href wins, so a * nested route (`/estado-cuenta/lote`) highlights its own entry instead of also * lighting up its parent (`/estado-cuenta`) — while `/estado-cuenta/`, which * has no entry of its own, still correctly highlights the parent. */ function activeHref(pathname: string | null): string | null { if (!pathname) return null; let best: string | null = null; for (const item of NAV_LINKS) { const under = (href: string) => pathname === href || pathname.startsWith(`${href}/`); const match = item.exact ? pathname === item.href : under(item.href) || (item.aliases?.some(under) ?? false); if (match && (best === null || item.href.length > best.length)) { best = item.href; } } return best; } /** * One collapsible group in the desktop bar. Opens on click, closes on outside * click, Escape, or navigation. The trigger stays highlighted while any of its * children is the current page. */ function NavMenu({ label, items, current, pathname, }: { label: string; items: NavLink[]; current: string | null; pathname: string | null; }) { const [open, setOpen] = useState(false); const ref = useRef(null); const holdsCurrent = items.some((item) => item.href === current); useEffect(() => { setOpen(false); }, [pathname]); useEffect(() => { if (!open) return; function onPointerDown(event: MouseEvent) { if (ref.current && !ref.current.contains(event.target as Node)) { setOpen(false); } } function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") setOpen(false); } document.addEventListener("mousedown", onPointerDown); document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("mousedown", onPointerDown); document.removeEventListener("keydown", onKeyDown); }; }, [open]); return (
{open && (
{items.map((item) => ( setOpen(false)} > {item.label} ))}
)}
); } /** * What is deployed, from both halves. build.yml builds api + web in one matrix * run, so their versions cannot drift at build time — but they can at DEPLOY * time, if a stack is applied with only one image's tag moved. Showing both and * flagging a mismatch is the cheap check that catches a half-applied release. */ function BuildFooter() { const web = webBuildInfo(); const [api, setApi] = useState(null); useEffect(() => { let alive = true; getApiVersion() .then((v) => { if (alive) setApi(v); }) .catch(() => { // The shell already redirects to /login when the API is unreachable; // a missing version line is not worth a second error surface. }); return () => { alive = false; }; }, []); // Compare the COMMIT, not the version string. On a branch build both tiers // report APP_VERSION "master", so comparing versions cannot see drift — which // is exactly how a stale web image once sat next to a current API with this // footer showing nothing wrong. The sha is the only field that actually // differs between two builds of the same branch. const mismatch = api !== null && api.gitSha !== web.gitSha; return (
Jorge Cuadros & Asociados {/* The FULL 40-char commit, not an abbreviation: this line exists to be pasted into `git show` or compared against a registry tag, and a 7-char prefix makes both a manual step. It is what GIT_SHA already carries — build.yml bakes in `github.sha` whole. */} v{web.version} · {web.gitSha} {mismatch && api ? ` · API ${api.gitSha}` : ""} {mismatch && ( versiones desincronizadas )}
); } export function AppShell({ children }: { children: ReactNode }) { const router = useRouter(); const pathname = usePathname(); const [user, setUser] = useState(null); const [checking, setChecking] = useState(true); const [loggingOut, setLoggingOut] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [uiScale, setUiScale] = useState(DEFAULT_UI_SCALE); const current = activeHref(pathname); const nav = visibleNav(user); useEffect(() => { let alive = true; me() .then((u) => { if (!alive) return; setUser(u); setChecking(false); // The account wins over the localStorage copy the pre-hydration script // painted with: that copy is this browser's, while the account follows // the person between machines. Re-save so the next cold paint here is // already correct. const accountScale = normalizeUiScale(u.uiScale ?? DEFAULT_UI_SCALE); setUiScale(accountScale); applyUiScale(accountScale); saveUiScale(accountScale); }) .catch(() => { router.replace("/login"); }); return () => { alive = false; }; }, [router]); // Before /auth/me answers, show whatever the pre-hydration script applied so // the control isn't briefly out of step with the page. useEffect(() => { setUiScale(readUiScale()); }, []); function changeUiScale(next: number) { setUiScale(next); applyUiScale(next); saveUiScale(next); setUser((prev) => (prev ? { ...prev, uiScale: next } : prev)); // Fire and forget: the change is already applied and cached locally, so a // failed write only means it won't follow the user to another machine. updateUiScale(next).catch(() => { /* ignore */ }); } // Navigating away closes the mobile drawer — the route change is the only // "done" signal we get from a . useEffect(() => { setDrawerOpen(false); }, [pathname]); useEffect(() => { if (!drawerOpen) return; function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") setDrawerOpen(false); } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [drawerOpen]); async function handleLogout() { setLoggingOut(true); try { await logout(); } catch { /* ignore — we redirect regardless */ } router.replace("/login"); } if (checking) { return (
); } return (
Jorge Cuadros & Asociados
{user && ( {user.name} {ROLE_LABEL[user.role]} )}
{drawerOpen && ( )}
{children}
); }