Closes the gap between "what tag did I deploy" and "what is actually running", and gives the schema a history that can be reasoned about across releases. Migrations - Baseline the existing schema as 0000_init (migrate diff --from-empty). The schema had only ever been applied with `prisma db push`, so no history existed and schema state was disconnected from app version. Existing databases must be baselined once with `migrate resolve --applied 0000_init`; the workflows print this remedy on P3005. - Run `prisma migrate deploy` as a deploy STEP, not the container CMD — as a CMD, N replicas would race each other applying the same migration. Version reporting - GET /version on the API reports the APP_VERSION / GIT_SHA / BUILD_DATE that build.yml already baked into both images but nothing ever read. - The web footer shows the web build and flags an api/web mismatch. The two cannot drift at build time (one matrix run) but can at deploy time. - Both deploy workflows now fail if the running API does not report the tag that was dispatched — a stack naming a tag is not proof of what is running. - scripts/set-version.mjs stamps every package.json, which had all sat at 0.1.0 while real releases shipped as v1.x. Pre-migrate backup - deploy/scripts/pre-migrate-backup.mjs dumps the database from INSIDE the still-running old API container over Portainer's Docker API, so the file lands in the volume the Operaciones restore screen reads. A dump taken on the CI runner would be unreachable by the only restore path we have. Verifies the artefact with `gzip -t` before letting the migration proceed. galactus - deploy/galactus/*.compose.yml: standalone-Docker ports of the Swarm stacks. Plain compose silently ignores `deploy:`, so restart_policy becomes `restart: unless-stopped` — without it nothing returns after a host reboot. - .gitea/workflows/deploy-galactus.yml drives endpoint 3 with its own secrets. Fixes - deploy.yml passed `endpoint_id` and `pull_image` to cssnr/portainer-stack-deploy-action, which has no such inputs (they are `endpoint` and `pull`). The endpoint was silently never set. docs/DEPLOY_AND_MIGRATIONS.md documents expand/contract as the rule for schema changes: Prisma has no down-migrations, so a code rollback never rolls the schema back, and restoring the replication master from a dump diverges every replica. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
"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 } from "@/lib/api";
|
|
import { shortSha, 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; exact?: boolean };
|
|
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.
|
|
{ href: "/estado-cuenta/lote", label: "Captura", ability: "ledger:create" },
|
|
{ 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",
|
|
},
|
|
{ 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);
|
|
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/<id>`, 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 match = item.exact
|
|
? pathname === item.href
|
|
: pathname === item.href || pathname.startsWith(`${item.href}/`);
|
|
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<HTMLDivElement>(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 (
|
|
<div className="appbar-menu" ref={ref}>
|
|
<button
|
|
type="button"
|
|
className={`appbar-link appbar-menu-trigger${holdsCurrent ? " active" : ""}`}
|
|
aria-expanded={open}
|
|
aria-haspopup="true"
|
|
onClick={() => setOpen((v) => !v)}
|
|
>
|
|
{label}
|
|
<span className="appbar-caret" aria-hidden="true" />
|
|
</button>
|
|
{open && (
|
|
<div className="appbar-dropdown" role="menu">
|
|
{items.map((item) => (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
role="menuitem"
|
|
className={`appbar-dropdown-link${current === item.href ? " active" : ""}`}
|
|
aria-current={current === item.href ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
getApiVersion()
|
|
.then((v) => {
|
|
if (alive) setApi(v.version);
|
|
})
|
|
.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;
|
|
};
|
|
}, []);
|
|
|
|
const mismatch = api !== null && api !== web.version;
|
|
|
|
return (
|
|
<footer className="shell-footer">
|
|
<span>Jorge Cuadros & Asociados</span>
|
|
<span
|
|
className="shell-footer-build"
|
|
title={`web ${web.version} (${shortSha(web.gitSha)}) — ${web.buildDate}`}
|
|
>
|
|
v{web.version}
|
|
{api !== null && (mismatch ? ` · API v${api}` : "")}
|
|
</span>
|
|
{mismatch && (
|
|
<span className="shell-footer-warn" role="status">
|
|
versiones desincronizadas
|
|
</span>
|
|
)}
|
|
</footer>
|
|
);
|
|
}
|
|
|
|
export function AppShell({ children }: { children: ReactNode }) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const [user, setUser] = useState<AuthUser | null>(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 <Link>.
|
|
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 (
|
|
<div
|
|
style={{
|
|
minHeight: "100vh",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
color: "var(--brand-700)",
|
|
}}
|
|
>
|
|
<span className="spinner" aria-label="Cargando" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AuthContext.Provider value={user}>
|
|
<header className="appbar">
|
|
<div className="appbar-inner">
|
|
<Link href="/inicio" className="brand">
|
|
<img
|
|
src="/images/company_logo.png"
|
|
alt=""
|
|
className="brand-mark"
|
|
/>
|
|
<span className="brand-text">
|
|
<span className="brand-name">Jorge Cuadros</span>
|
|
<span className="brand-sub">& Asociados</span>
|
|
</span>
|
|
</Link>
|
|
<nav className="appbar-nav" aria-label="Principal">
|
|
{nav.map((entry) =>
|
|
entry.kind === "link" ? (
|
|
<Link
|
|
key={entry.href}
|
|
href={entry.href}
|
|
className={`appbar-link${current === entry.href ? " active" : ""}`}
|
|
aria-current={current === entry.href ? "page" : undefined}
|
|
>
|
|
{entry.label}
|
|
</Link>
|
|
) : (
|
|
<NavMenu
|
|
key={entry.label}
|
|
label={entry.label}
|
|
items={entry.items}
|
|
current={current}
|
|
pathname={pathname}
|
|
/>
|
|
),
|
|
)}
|
|
</nav>
|
|
<span className="appbar-spacer" />
|
|
<div className="appbar-user">
|
|
<FontScaleControl value={uiScale} onChange={changeUiScale} />
|
|
{user && (
|
|
<span className="appbar-user-name">
|
|
{user.name}
|
|
<span className="appbar-user-role">{ROLE_LABEL[user.role]}</span>
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
className="btn btn-ghost"
|
|
onClick={handleLogout}
|
|
disabled={loggingOut}
|
|
>
|
|
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="appbar-burger"
|
|
aria-label={drawerOpen ? "Cerrar menú" : "Abrir menú"}
|
|
aria-expanded={drawerOpen}
|
|
onClick={() => setDrawerOpen((v) => !v)}
|
|
>
|
|
<span className={`burger-icon${drawerOpen ? " open" : ""}`} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
{drawerOpen && (
|
|
<nav className="appbar-drawer" aria-label="Principal (móvil)">
|
|
{nav.map((entry) =>
|
|
entry.kind === "link" ? (
|
|
<Link
|
|
key={entry.href}
|
|
href={entry.href}
|
|
className={`appbar-drawer-link${current === entry.href ? " active" : ""}`}
|
|
aria-current={current === entry.href ? "page" : undefined}
|
|
>
|
|
{entry.label}
|
|
</Link>
|
|
) : (
|
|
<div key={entry.label} className="appbar-drawer-group">
|
|
<span className="appbar-drawer-heading">{entry.label}</span>
|
|
{entry.items.map((item) => (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
className={`appbar-drawer-link${current === item.href ? " active" : ""}`}
|
|
aria-current={current === item.href ? "page" : undefined}
|
|
>
|
|
{item.label}
|
|
</Link>
|
|
))}
|
|
</div>
|
|
),
|
|
)}
|
|
<FontScaleControl
|
|
value={uiScale}
|
|
onChange={changeUiScale}
|
|
variant="inline"
|
|
/>
|
|
{user && (
|
|
<div className="appbar-drawer-user">
|
|
{user.name} · {ROLE_LABEL[user.role]}
|
|
</div>
|
|
)}
|
|
</nav>
|
|
)}
|
|
</header>
|
|
<main className="shell-main">{children}</main>
|
|
<BuildFooter />
|
|
</AuthContext.Provider>
|
|
);
|
|
}
|