Web: Spanish-first staff UI — login + unified customer browser
First real frontend feature against the live Customer module API.
- login/ — session login form posting to /auth/login with credentials
included; the session cookie is what every subsequent request rides on.
- clientes/ — customer list with search and the cross-line stats header
(customers, utilities/insurance split, both-lines count).
- clientes/[id]/ — unified detail view: identity, properties + services,
policies, and transaction history for one customer, which is the whole
point of the migration (one record spanning both business lines).
- components/AppShell.tsx, lib/{api,labels,types}.ts — shared fetch wrapper
(always credentials: "include"), Spanish label maps for the enum values
the API returns, and the API response types.
- globals.css + layout.tsx — Spanish-first document (lang="es"), the type
scale, and the design tokens the pages share. Fonts load via <link> so an
offline build still renders on the system fallback stacks.
- page.tsx now redirects / to /clientes.
Also fixes pnpm-workspace.yaml: the allowBuilds map held pnpm's literal
placeholder text ("set this to true or false"), which made every install
fail with ERR_PNPM_IGNORED_BUILDS. Since pnpm 11 auto-installs before
running a script, that broke `pnpm start:dev` outright. Set the values to
true and dropped the superseded onlyBuiltDependencies list.
Verified: both apps build clean, and login -> /auth/me -> /customers/stats
round-trips against the dev database (1682 customers, 526 on both lines).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ApiError, login, me } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bootChecking, setBootChecking] = useState(true);
|
||||
|
||||
// If already signed in, skip straight to the customer browser.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
me()
|
||||
.then(() => router.replace("/clientes"))
|
||||
.catch(() => {
|
||||
if (alive) setBootChecking(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email.trim(), password);
|
||||
router.replace("/clientes");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
setError("Correo o contraseña incorrectos");
|
||||
} else if (err instanceof ApiError && err.status === 0) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("No se pudo iniciar sesión. Inténtalo de nuevo.");
|
||||
}
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (bootChecking) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--brand-700)",
|
||||
}}
|
||||
>
|
||||
<span className="spinner" aria-label="Cargando" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-wrap">
|
||||
{/* Brand / narrative panel */}
|
||||
<aside className="login-aside" aria-hidden="false">
|
||||
<div className="login-aside-top">
|
||||
<div className="login-brand">
|
||||
<span className="brand-mark" aria-hidden>
|
||||
JC
|
||||
</span>
|
||||
<div className="brand-text">
|
||||
<span className="brand-name" style={{ color: "#f6f3ec" }}>
|
||||
Jorge Cuadros
|
||||
</span>
|
||||
<span className="brand-sub">& Asociados</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="login-aside-mid">
|
||||
<p className="eyebrow" style={{ color: "rgba(242,239,231,0.6)" }}>
|
||||
Plataforma interna
|
||||
</p>
|
||||
<h1 className="login-headline">
|
||||
Un solo expediente para <em>Servicios</em> y <em>Seguros</em>.
|
||||
</h1>
|
||||
<p className="login-lede">
|
||||
Consulta en un mismo lugar las propiedades, pólizas y el estado de
|
||||
cuenta de cada cliente en Baja California.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="login-aside-foot">
|
||||
<div className="login-lob">
|
||||
<span className="badge badge-servicios">
|
||||
<span className="dot" /> Servicios
|
||||
</span>
|
||||
<span className="badge badge-seguros">
|
||||
<span className="dot" /> Seguros
|
||||
</span>
|
||||
</div>
|
||||
<span className="login-foot-note">
|
||||
Gestión de propiedades y correduría de seguros
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Form panel */}
|
||||
<section className="login-form-panel">
|
||||
<div className="login-form-inner rise">
|
||||
<p className="eyebrow">Acceso del personal</p>
|
||||
<h2 className="login-form-title">Iniciar sesión</h2>
|
||||
<p className="muted" style={{ marginTop: 6, marginBottom: 28 }}>
|
||||
Ingresa con tu cuenta para continuar.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate>
|
||||
<label className="field">
|
||||
<span className="field-label">Correo electrónico</span>
|
||||
<input
|
||||
type="email"
|
||||
autoComplete="username"
|
||||
className="input"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="nombre@jorgecuadros.local"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="field">
|
||||
<span className="field-label">Contraseña</span>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="input"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div className="login-error" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary login-submit"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
<span className="spinner" style={{ width: 15, height: 15 }} />
|
||||
Entrando…
|
||||
</>
|
||||
) : (
|
||||
"Entrar"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user