Files
jorgecuadros-platform/apps/web/src/components/AppShell.tsx
T
rmancinasandClaude Opus 5 0332292ae9
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m22s
fix(notificaciones): merge renewals into one screen, fix MailModule DI
MailModule's provider used a `useFactory` with no `inject`, so the factory
received `undefined` and `new MailService(config)` threw on `config.get`,
taking the whole API down at boot. The module also wasn't actually
`@Global()` even though both NotificationsModule and RenewalsModule inject
MailService without importing it — that would have failed next. Replaced the
factory with a plain provider (ConfigModule is already `isGlobal`) and marked
the module global.

On the web side, mass email and renewal notices were two menu entries doing
the same job — telling a customer something by email. They are now two tabs
of `/notificaciones` (Servicios and Pólizas), following the Captura pattern:
`/renovaciones` still resolves, opening the same screen on its Pólizas tab so
existing bookmarks keep working.

The notifications page was also the last screen written in raw inline styles,
with blue buttons and filter pills that appear nowhere else in the app. It now
uses the shared design system: btn-primary/btn-outline, the seg segmented
control, card, tx-table, pager, and the servicios/fideicomiso badges.

Two supporting fixes found on the way: NOTIFICATION_STATUS_COLORS hardcoded
hex instead of the theme's positive/negative/muted vars, and `.small` was
referenced in 19 places across the app but never defined in globals.css.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 02:21:51 -07:00

475 lines
16 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, 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/<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 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<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<ServiceVersion | null>(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 (
<footer className="shell-footer">
<span>Jorge Cuadros &amp; Asociados</span>
{/* 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. */}
<span
className="shell-footer-build"
title={`web ${web.version} (${web.gitSha}) — ${web.buildDate}`}
>
v{web.version} · {web.gitSha}
{mismatch && api ? ` · API ${api.gitSha}` : ""}
</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>
);
}