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:
@@ -12,4 +12,6 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
packages/database/generated/
|
packages/database/generated/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { getCustomer } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
domainLabel,
|
||||||
|
formatDate,
|
||||||
|
formatMoney,
|
||||||
|
serviceKindGlyph,
|
||||||
|
serviceKindLabel,
|
||||||
|
sourceSystemLabel,
|
||||||
|
} from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
CustomerDetail,
|
||||||
|
Installment,
|
||||||
|
Policy,
|
||||||
|
Property,
|
||||||
|
Transaction,
|
||||||
|
TransactionSummaryRow,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
export default function ClienteDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const { id } = params;
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<Detail id={id} />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const [data, setData] = useState<CustomerDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getCustomer(id)
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) {
|
||||||
|
setData(d);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) {
|
||||||
|
setError(
|
||||||
|
e?.status === 404
|
||||||
|
? "No encontramos este cliente."
|
||||||
|
: e?.message ?? "No se pudo cargar el cliente.",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) return <DetailSkeleton />;
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BackLink />
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const hasUtilities = data.properties.length > 0;
|
||||||
|
const hasInsurance = data.policies.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rise">
|
||||||
|
<BackLink />
|
||||||
|
<Hero data={data} hasUtilities={hasUtilities} hasInsurance={hasInsurance} />
|
||||||
|
|
||||||
|
<DatosSection data={data} />
|
||||||
|
<PropiedadesSection properties={data.properties} />
|
||||||
|
<PolizasSection policies={data.policies} />
|
||||||
|
<EstadoCuentaSection
|
||||||
|
summary={data.transactionSummary}
|
||||||
|
transactions={data.transactions}
|
||||||
|
/>
|
||||||
|
<DocumentosSection data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackLink() {
|
||||||
|
return (
|
||||||
|
<Link href="/clientes" className="back-link">
|
||||||
|
← Volver a Clientes
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Hero */
|
||||||
|
function Hero({
|
||||||
|
data,
|
||||||
|
hasUtilities,
|
||||||
|
hasInsurance,
|
||||||
|
}: {
|
||||||
|
data: CustomerDetail;
|
||||||
|
hasUtilities: boolean;
|
||||||
|
hasInsurance: boolean;
|
||||||
|
}) {
|
||||||
|
const provenance = data.legacyRefs
|
||||||
|
.map((r) => `${sourceSystemLabel(r.sourceSystem)} #${r.legacyId}`)
|
||||||
|
.join(" · ");
|
||||||
|
|
||||||
|
const facts: { label: string; value: string }[] = [
|
||||||
|
{ label: "Cliente desde", value: formatDate(data.customerSince) },
|
||||||
|
{
|
||||||
|
label: "Cuota",
|
||||||
|
value:
|
||||||
|
data.feeAmount != null && data.feeAmount !== ""
|
||||||
|
? formatMoney(data.feeAmount, data.preferredCurrency)
|
||||||
|
: "—",
|
||||||
|
},
|
||||||
|
{ label: "Propiedades", value: String(data.properties.length) },
|
||||||
|
{ label: "Pólizas", value: String(data.policies.length) },
|
||||||
|
{ label: "Moneda", value: data.preferredCurrency ?? "—" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="detail-hero">
|
||||||
|
<div className="hero-top">
|
||||||
|
<div>
|
||||||
|
<h1 className="hero-name">{data.name}</h1>
|
||||||
|
{provenance && (
|
||||||
|
<div className="hero-provenance">Origen: {provenance}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="hero-badges">
|
||||||
|
{hasUtilities && (
|
||||||
|
<span className="badge badge-servicios">
|
||||||
|
<span className="dot" /> Servicios
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasInsurance && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> Seguros
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
data.status ? "badge-on-dark" : "badge-negative"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{data.status ? "Activo" : "Inactivo"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hero-facts">
|
||||||
|
{facts.map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<div className="hero-fact-label">{f.label}</div>
|
||||||
|
<div className="hero-fact-value">{f.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- Datos del cliente */
|
||||||
|
function DatosSection({ data }: { data: CustomerDetail }) {
|
||||||
|
const mxAddress = [data.addressLine1, data.addressLine2]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const cityLine = [
|
||||||
|
data.city?.replace(/,\s*$/, ""),
|
||||||
|
data.state,
|
||||||
|
data.zipCode,
|
||||||
|
data.country,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const idLine =
|
||||||
|
data.identificationNumber || data.identificationType
|
||||||
|
? [
|
||||||
|
data.identificationType,
|
||||||
|
data.identificationNumber,
|
||||||
|
data.identificationExpiration
|
||||||
|
? `vence ${formatDate(data.identificationExpiration)}`
|
||||||
|
: null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="datos" title="Datos del cliente" />
|
||||||
|
<div className="card">
|
||||||
|
<div className="kv-grid">
|
||||||
|
<KV label="Teléfono" value={data.phone} mono />
|
||||||
|
<KV label="Móvil" value={data.mobile} mono />
|
||||||
|
<KV label="Fax" value={data.fax} mono />
|
||||||
|
<KV label="Correo electrónico" value={data.email} />
|
||||||
|
<KV label="Documento de identidad" value={idLine} />
|
||||||
|
<KV
|
||||||
|
label="Estado"
|
||||||
|
value={data.status ? "Activo" : "Inactivo"}
|
||||||
|
/>
|
||||||
|
{(mxAddress || cityLine) && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Domicilio</div>
|
||||||
|
<div className="kv-value">
|
||||||
|
{mxAddress && <div>{mxAddress}</div>}
|
||||||
|
{cityLine && <div>{cityLine}</div>}
|
||||||
|
{!mxAddress && !cityLine && "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{data.notes && (
|
||||||
|
<div className="kv-block">
|
||||||
|
<div className="kv-label">Notas</div>
|
||||||
|
<div className="kv-value">{data.notes}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KV({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
mono,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | null | undefined;
|
||||||
|
mono?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="kv-label">{label}</div>
|
||||||
|
<div className={`kv-value${mono && value ? " mono" : ""}`}>
|
||||||
|
{value || "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------- Propiedades y servicios */
|
||||||
|
function PropiedadesSection({ properties }: { properties: Property[] }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="servicios"
|
||||||
|
title="Propiedades y servicios"
|
||||||
|
count={properties.length}
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
{properties.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Este cliente no tiene propiedades registradas.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
properties.map((p) => <PropertyCard key={p.id} p={p} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyCard({ p }: { p: Property }) {
|
||||||
|
const addr = [p.addressLine1, p.addressLine2].filter(Boolean).join(", ");
|
||||||
|
const phones = [p.phone1, p.phone2, p.phone3].filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="prop-card">
|
||||||
|
<div className="prop-addr">{addr || "Propiedad"}</div>
|
||||||
|
<div className="prop-meta">
|
||||||
|
{p.zone && <span>Zona: {p.zone}</span>}
|
||||||
|
{phones.length > 0 && (
|
||||||
|
<span className="mono">{phones.join(" · ")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.services.length > 0 && (
|
||||||
|
<div className="svc-grid">
|
||||||
|
{p.services.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className={`svc-item${s.active ? "" : " inactive"}`}
|
||||||
|
>
|
||||||
|
<div className="svc-head">
|
||||||
|
<span className="svc-kind">
|
||||||
|
<span className="svc-glyph" aria-hidden>
|
||||||
|
{serviceKindGlyph(s.kind)}
|
||||||
|
</span>
|
||||||
|
{serviceKindLabel(s.kind)}
|
||||||
|
</span>
|
||||||
|
{!s.active && (
|
||||||
|
<span className="badge badge-neutral">Inactivo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="svc-detail">
|
||||||
|
{s.accountNumber && (
|
||||||
|
<span>
|
||||||
|
Cuenta: <span className="mono">{s.accountNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.meterNumber && (
|
||||||
|
<span>
|
||||||
|
Medidor: <span className="mono">{s.meterNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.route && (
|
||||||
|
<span>
|
||||||
|
Ruta: <span className="mono">{s.route}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{s.dueDay && <span>Día de pago: {s.dueDay}</span>}
|
||||||
|
{s.notes && <span>{s.notes}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.trustAccount && (
|
||||||
|
<div className="trust-box">
|
||||||
|
<div className="trust-title">Fideicomiso</div>
|
||||||
|
<div className="trust-facts">
|
||||||
|
{p.trustAccount.bankName && (
|
||||||
|
<span>
|
||||||
|
<strong>Banco:</strong> {p.trustAccount.bankName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.trustAccount.trustNumber && (
|
||||||
|
<span>
|
||||||
|
<strong>No.:</strong>{" "}
|
||||||
|
<span className="mono">{p.trustAccount.trustNumber}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.trustAccount.bankFee && (
|
||||||
|
<span>
|
||||||
|
<strong>Comisión:</strong>{" "}
|
||||||
|
{formatMoney(p.trustAccount.bankFee, "MXN")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.trustAccount.dueDate1 && (
|
||||||
|
<span>
|
||||||
|
<strong>Vigencia:</strong>{" "}
|
||||||
|
{formatDate(p.trustAccount.dueDate1)}
|
||||||
|
{p.trustAccount.dueDate2
|
||||||
|
? ` – ${formatDate(p.trustAccount.dueDate2)}`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------ Pólizas de seguro */
|
||||||
|
function PolizasSection({ policies }: { policies: Policy[] }) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="seguros"
|
||||||
|
title="Pólizas de seguro"
|
||||||
|
count={policies.length}
|
||||||
|
/>
|
||||||
|
<div className="card">
|
||||||
|
{policies.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
Este cliente no tiene pólizas registradas.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
policies.map((p) => <PolicyCard key={p.id} p={p} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PolicyCard({ p }: { p: Policy }) {
|
||||||
|
const headline = p.total ?? p.netPremium;
|
||||||
|
const headlineLabel = p.total ? "Total" : "Prima neta";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="policy-card">
|
||||||
|
<div className="policy-head">
|
||||||
|
<div>
|
||||||
|
<div className="policy-num">{p.policyNumber || "—"}</div>
|
||||||
|
<div className="policy-type-row">
|
||||||
|
{p.policyType?.name && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> {p.policyType.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.insuranceProvider?.name && (
|
||||||
|
<span>{p.insuranceProvider.name}</span>
|
||||||
|
)}
|
||||||
|
{p.agentName && (
|
||||||
|
<>
|
||||||
|
<span className="sep">·</span>
|
||||||
|
<span>Agente: {p.agentName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`badge ${
|
||||||
|
p.liquidated ? "badge-positive" : "badge-neutral"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.liquidated ? "Liquidada" : "Pendiente"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="policy-type-row">
|
||||||
|
<span className="kv-label" style={{ margin: 0 }}>
|
||||||
|
Vigencia:
|
||||||
|
</span>
|
||||||
|
<span className="mono">
|
||||||
|
{formatDate(p.policyFrom)} – {formatDate(p.policyTo)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="policy-figures">
|
||||||
|
<div className="policy-total">
|
||||||
|
{formatMoney(headline, p.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="policy-total-label">{headlineLabel}</div>
|
||||||
|
{p.total && p.netPremium && (
|
||||||
|
<div className="muted" style={{ fontSize: 12, marginTop: 4 }}>
|
||||||
|
Prima neta {formatMoney(p.netPremium, p.currency)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="policy-body">
|
||||||
|
{p.installments.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Pagos</span>
|
||||||
|
<span>{p.installments.length}</span>
|
||||||
|
</div>
|
||||||
|
{p.installments.map((inst) => (
|
||||||
|
<InstallmentRow key={inst.id} inst={inst} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.vehicles.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Vehículos</span>
|
||||||
|
<span>{p.vehicles.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{p.vehicles.map((v) => (
|
||||||
|
<div key={v.id}>
|
||||||
|
{[v.make, v.model, v.modelYear].filter(Boolean).join(" ") ||
|
||||||
|
"Vehículo"}
|
||||||
|
<div className="mini-sub">
|
||||||
|
{[
|
||||||
|
v.bodyType,
|
||||||
|
v.licensePlate ? `Placa ${v.licensePlate}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.insuredDrivers.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Asegurados</span>
|
||||||
|
<span>{p.insuredDrivers.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{p.insuredDrivers.map((d) => (
|
||||||
|
<div key={d.id}>
|
||||||
|
{d.fullName || "—"}
|
||||||
|
{d.licenseNumber && (
|
||||||
|
<div className="mini-sub mono">Lic. {d.licenseNumber}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{p.beneficiaries.length > 0 && (
|
||||||
|
<div className="subpanel">
|
||||||
|
<div className="subpanel-title">
|
||||||
|
<span>Beneficiarios</span>
|
||||||
|
<span>{p.beneficiaries.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mini-list">
|
||||||
|
{p.beneficiaries.map((b) => (
|
||||||
|
<div key={b.id}>
|
||||||
|
{b.name || "—"}
|
||||||
|
{(b.phone || b.email) && (
|
||||||
|
<div className="mini-sub">
|
||||||
|
{[b.phone, b.email].filter(Boolean).join(" · ")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstallmentRow({ inst }: { inst: Installment }) {
|
||||||
|
const method = inst.isCash
|
||||||
|
? "Efectivo"
|
||||||
|
: inst.checkNumber
|
||||||
|
? `Ref. ${inst.checkNumber}`
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="pay-row">
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 9 }}>
|
||||||
|
<span className="pay-seq">{inst.sequence}</span>
|
||||||
|
<span>
|
||||||
|
{inst.paidDate ? formatDate(inst.paidDate) : "Sin pagar"}
|
||||||
|
{method && (
|
||||||
|
<span className="muted" style={{ fontSize: 11 }}>
|
||||||
|
{" "}
|
||||||
|
· {method}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="mono" style={{ fontWeight: 600 }}>
|
||||||
|
{formatMoney(inst.amount, inst.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------- Estado de cuenta */
|
||||||
|
function EstadoCuentaSection({
|
||||||
|
summary,
|
||||||
|
transactions,
|
||||||
|
}: {
|
||||||
|
summary: TransactionSummaryRow[];
|
||||||
|
transactions: Transaction[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead
|
||||||
|
rule="cuenta"
|
||||||
|
title="Estado de cuenta"
|
||||||
|
count={transactions.length}
|
||||||
|
countSuffix="movimientos"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{summary.length > 0 && (
|
||||||
|
<div className="summary-grid">
|
||||||
|
{summary.map((row, i) => (
|
||||||
|
<div className={`summary-card ${row.domain}`} key={i}>
|
||||||
|
<div className="summary-domain">
|
||||||
|
<span className={`tx-dot ${row.domain}`} />
|
||||||
|
{domainLabel(row.domain)} · {row.currency}
|
||||||
|
</div>
|
||||||
|
<div className="summary-total">
|
||||||
|
{formatMoney(row.total, row.currency)}
|
||||||
|
</div>
|
||||||
|
<div className="summary-count">
|
||||||
|
{row.count}{" "}
|
||||||
|
{row.count === 1 ? "movimiento" : "movimientos"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<div className="empty-inline">Sin movimientos registrados.</div>
|
||||||
|
) : (
|
||||||
|
<div className="tx-scroll">
|
||||||
|
<table className="tx-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Línea</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Concepto</th>
|
||||||
|
<th className="num">Monto</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map((t) => (
|
||||||
|
<TxRow key={t.id} t={t} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{transactions.length >= 100 && (
|
||||||
|
<div className="section-note" style={{ padding: "0 16px 14px" }}>
|
||||||
|
Mostrando los 100 movimientos más recientes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TxRow({ t }: { t: Transaction }) {
|
||||||
|
const num = t.amount != null ? Number(t.amount) : NaN;
|
||||||
|
const sign = !Number.isNaN(num) && num < 0 ? "neg" : "pos";
|
||||||
|
const tipo =
|
||||||
|
t.type?.nameEs || t.type?.nameEn || "—";
|
||||||
|
const concept = t.message || t.period || "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="mono" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(t.transactionDate)}
|
||||||
|
</td>
|
||||||
|
<td className="tx-domain-cell">
|
||||||
|
<span className={`tx-dot ${t.domain}`} />
|
||||||
|
{domainLabel(t.domain)}
|
||||||
|
</td>
|
||||||
|
<td>{tipo}</td>
|
||||||
|
<td className="tx-ref">{t.reference || "—"}</td>
|
||||||
|
<td className="tx-concept">{concept}</td>
|
||||||
|
<td className="num">
|
||||||
|
<span className={`tx-amount ${sign}`}>
|
||||||
|
{formatMoney(t.amount, t.currency)}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="tx-cur">{t.currency}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Documentos */
|
||||||
|
function DocumentosSection({ data }: { data: CustomerDetail }) {
|
||||||
|
type Doc = { type: string; key: string | null; scope: string };
|
||||||
|
const docs: Doc[] = [];
|
||||||
|
data.properties.forEach((p) => {
|
||||||
|
const label = [p.addressLine1].filter(Boolean).join("") || "Propiedad";
|
||||||
|
p.documents.forEach((d) =>
|
||||||
|
docs.push({
|
||||||
|
type: d.documentType || "Documento",
|
||||||
|
key: d.storageKey,
|
||||||
|
scope: label,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
data.policies.forEach((p) => {
|
||||||
|
p.documents.forEach((d) =>
|
||||||
|
docs.push({
|
||||||
|
type: d.documentType || "Documento",
|
||||||
|
key: d.storageKey,
|
||||||
|
scope: `Póliza ${p.policyNumber ?? ""}`.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="section">
|
||||||
|
<SectionHead rule="docs" title="Documentos" count={docs.length} />
|
||||||
|
<div className="card">
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<div className="empty-inline">
|
||||||
|
No hay documentos registrados para este cliente.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="doc-list">
|
||||||
|
{docs.map((d, i) => (
|
||||||
|
<div className="doc-item" key={i}>
|
||||||
|
<span className="doc-icon" aria-hidden>
|
||||||
|
▤
|
||||||
|
</span>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div className="doc-type">{d.type}</div>
|
||||||
|
<div className="doc-key">{d.scope}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="section-note"
|
||||||
|
style={{ padding: "0 22px 18px" }}
|
||||||
|
>
|
||||||
|
Los archivos se almacenan en el object storage
|
||||||
|
(storageKey); no se descargan desde esta vista.
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- helpers */
|
||||||
|
function SectionHead({
|
||||||
|
rule,
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
countSuffix,
|
||||||
|
}: {
|
||||||
|
rule: string;
|
||||||
|
title: string;
|
||||||
|
count?: number;
|
||||||
|
countSuffix?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="section-head">
|
||||||
|
<span className={`section-rule ${rule}`} aria-hidden />
|
||||||
|
<h2 className="section-title">{title}</h2>
|
||||||
|
{count != null && (
|
||||||
|
<span className="section-count">
|
||||||
|
{count} {countSuffix ?? ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 16, width: 140, marginBottom: 18 }}
|
||||||
|
/>
|
||||||
|
<div className="skeleton" style={{ height: 180, borderRadius: 16 }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 200, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 260, borderRadius: 16, marginTop: 34 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
import { getStats, listCustomers } from "@/lib/api";
|
||||||
|
import { formatNumber } from "@/lib/labels";
|
||||||
|
import type {
|
||||||
|
BusinessLine,
|
||||||
|
CustomerListItem,
|
||||||
|
CustomerListResponse,
|
||||||
|
CustomerStats,
|
||||||
|
} from "@/lib/types";
|
||||||
|
|
||||||
|
type Filter = "all" | BusinessLine;
|
||||||
|
|
||||||
|
const FILTERS: { key: Filter; label: string }[] = [
|
||||||
|
{ key: "all", label: "Todos" },
|
||||||
|
{ key: "utility", label: "Servicios" },
|
||||||
|
{ key: "insurance", label: "Seguros" },
|
||||||
|
{ key: "both", label: "Ambos" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ClientesPage() {
|
||||||
|
return (
|
||||||
|
<AppShell>
|
||||||
|
<ClientesBrowser />
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClientesBrowser() {
|
||||||
|
const [stats, setStats] = useState<CustomerStats | null>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const [data, setData] = useState<CustomerListResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getStats().then(setStats).catch(() => setStats(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runSearch = useCallback(
|
||||||
|
(q: string, f: Filter, p: number) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
listCustomers({
|
||||||
|
query: q || undefined,
|
||||||
|
line: f === "all" ? undefined : f,
|
||||||
|
page: p,
|
||||||
|
pageSize: 25,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
setData(res);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setError(
|
||||||
|
e?.message ?? "No se pudieron cargar los clientes.",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debounced search on query/filter change; resets to page 1.
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setPage(1);
|
||||||
|
runSearch(query, filter, 1);
|
||||||
|
}, 280);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [query, filter]);
|
||||||
|
|
||||||
|
function goToPage(p: number) {
|
||||||
|
setPage(p);
|
||||||
|
runSearch(query, filter, p);
|
||||||
|
if (typeof window !== "undefined")
|
||||||
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head rise">
|
||||||
|
<p className="eyebrow">Directorio unificado</p>
|
||||||
|
<h1 className="page-title">Clientes</h1>
|
||||||
|
<StatStrip stats={stats} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="toolbar">
|
||||||
|
<div className="search-box">
|
||||||
|
<span className="search-icon" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="input search-input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Buscar por nombre, ciudad, teléfono…"
|
||||||
|
aria-label="Buscar clientes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="seg"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Filtrar por línea de negocio"
|
||||||
|
>
|
||||||
|
{FILTERS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={filter === f.key}
|
||||||
|
className={`seg-btn ${filter === f.key ? "active" : ""}`}
|
||||||
|
onClick={() => setFilter(f.key)}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data && !loading && !error && (
|
||||||
|
<div className="result-meta" aria-live="polite">
|
||||||
|
{data.total === 0
|
||||||
|
? "Sin resultados"
|
||||||
|
: `${formatNumber(data.total)} ${
|
||||||
|
data.total === 1 ? "cliente" : "clientes"
|
||||||
|
}`}
|
||||||
|
{query ? ` para “${query}”` : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="state-error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<ListSkeleton />
|
||||||
|
) : data && data.items.length === 0 ? (
|
||||||
|
<EmptyState query={query} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="cust-list">
|
||||||
|
{data?.items.map((c) => (
|
||||||
|
<CustomerRow key={c.id} c={c} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{data && data.pageCount > 1 && (
|
||||||
|
<Pager
|
||||||
|
page={data.page}
|
||||||
|
pageCount={data.pageCount}
|
||||||
|
onChange={goToPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatStrip({ stats }: { stats: CustomerStats | null }) {
|
||||||
|
const cells: {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
accent?: boolean;
|
||||||
|
}[] = stats
|
||||||
|
? [
|
||||||
|
{ value: formatNumber(stats.customers), label: "Clientes", accent: true },
|
||||||
|
{ value: formatNumber(stats.withUtilities), label: "Con servicios" },
|
||||||
|
{ value: formatNumber(stats.withInsurance), label: "Con seguros" },
|
||||||
|
{ value: formatNumber(stats.bothLines), label: "Ambas líneas", accent: true },
|
||||||
|
{ value: formatNumber(stats.policies), label: "Pólizas" },
|
||||||
|
{ value: formatNumber(stats.properties), label: "Propiedades" },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return (
|
||||||
|
<div className="stat-strip" aria-hidden>
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div className="stat-cell" key={i}>
|
||||||
|
<div className="skeleton" style={{ height: 25, width: "60%" }} />
|
||||||
|
<div
|
||||||
|
className="skeleton"
|
||||||
|
style={{ height: 11, width: "80%", marginTop: 8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="stat-strip">
|
||||||
|
{cells.map((c) => (
|
||||||
|
<div
|
||||||
|
className={`stat-cell${c.accent ? " accent" : ""}`}
|
||||||
|
key={c.label}
|
||||||
|
>
|
||||||
|
<div className="stat-value">{c.value}</div>
|
||||||
|
<div className="stat-label">{c.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomerRow({ c }: { c: CustomerListItem }) {
|
||||||
|
const location = [c.city?.replace(/,\s*$/, ""), c.state]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
const contact = c.phone || c.mobile || c.email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/clientes/${c.id}`} className="cust-row">
|
||||||
|
<div className="cust-main">
|
||||||
|
<div className="cust-name">
|
||||||
|
{!c.status && (
|
||||||
|
<span className="inactive-dot" title="Inactivo" aria-hidden />
|
||||||
|
)}
|
||||||
|
{c.name}
|
||||||
|
</div>
|
||||||
|
<div className="cust-sub">
|
||||||
|
{location && <span>{location}</span>}
|
||||||
|
{location && contact && <span className="sep">·</span>}
|
||||||
|
{contact && <span>{contact}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cust-side">
|
||||||
|
{c.hasUtilities && (
|
||||||
|
<span className="badge badge-servicios">
|
||||||
|
<span className="dot" /> Servicios
|
||||||
|
{c.propertyCount > 0 && (
|
||||||
|
<span className="badge-count">· {c.propertyCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{c.hasInsurance && (
|
||||||
|
<span className="badge badge-seguros">
|
||||||
|
<span className="dot" /> Seguros
|
||||||
|
{c.policyCount > 0 && (
|
||||||
|
<span className="badge-count">· {c.policyCount}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pager({
|
||||||
|
page,
|
||||||
|
pageCount,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageCount: number;
|
||||||
|
onChange: (p: number) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<nav className="pager" aria-label="Paginación">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
>
|
||||||
|
← Anterior
|
||||||
|
</button>
|
||||||
|
<span className="pager-info">
|
||||||
|
Página <strong>{page}</strong> de {pageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={() => onChange(page + 1)}
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
>
|
||||||
|
Siguiente →
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="cust-list" aria-hidden>
|
||||||
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
|
<div className="skeleton skel-row" key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ query }: { query: string }) {
|
||||||
|
return (
|
||||||
|
<div className="state-box">
|
||||||
|
<div className="state-glyph" aria-hidden>
|
||||||
|
⌕
|
||||||
|
</div>
|
||||||
|
<h3>Sin resultados</h3>
|
||||||
|
<p>
|
||||||
|
{query
|
||||||
|
? `No encontramos clientes para “${query}”.`
|
||||||
|
: "No hay clientes que coincidan con el filtro."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,29 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Jorge Cuadros & Assoc.",
|
title: "Jorge Cuadros & Asociados — Plataforma",
|
||||||
description: "Unified customer, insurance, and utilities platform",
|
description:
|
||||||
|
"Plataforma interna unificada de clientes, servicios y seguros.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
{/* Google Fonts via <link> so an offline build still runs with the
|
||||||
|
system fallback stacks defined in globals.css. */}
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link
|
||||||
|
rel="preconnect"
|
||||||
|
href="https://fonts.gstatic.com"
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,560;9..144,600&family=Work+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
redirect("/clientes");
|
||||||
<main>
|
|
||||||
<h1>Jorge Cuadros & Assoc.</h1>
|
|
||||||
<p>Unified customer platform — scaffold in progress.</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { logout, me } from "@/lib/api";
|
||||||
|
import type { AuthUser } 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. Used by /clientes and /clientes/[id].
|
||||||
|
*/
|
||||||
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
me()
|
||||||
|
.then((u) => {
|
||||||
|
if (alive) {
|
||||||
|
setUser(u);
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
router.replace("/login");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<header className="appbar">
|
||||||
|
<div className="appbar-inner">
|
||||||
|
<Link href="/clientes" className="brand">
|
||||||
|
<span className="brand-mark" aria-hidden>
|
||||||
|
JC
|
||||||
|
</span>
|
||||||
|
<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">
|
||||||
|
<Link href="/clientes" className="appbar-link active">
|
||||||
|
Clientes
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<span className="appbar-spacer" />
|
||||||
|
<div className="appbar-user">
|
||||||
|
{user && (
|
||||||
|
<span className="appbar-user-name">{user.name}</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={loggingOut}
|
||||||
|
>
|
||||||
|
{loggingOut ? "Saliendo…" : "Cerrar sesión"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="shell-main">{children}</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Cross-origin API client. All requests send the session cookie (connect.sid)
|
||||||
|
// via credentials: "include". The API origin comes from NEXT_PUBLIC_API_ORIGIN.
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AuthUser,
|
||||||
|
BusinessLine,
|
||||||
|
CustomerDetail,
|
||||||
|
CustomerListResponse,
|
||||||
|
CustomerStats,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export const API_ORIGIN =
|
||||||
|
process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:3001";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
init?: RequestInit,
|
||||||
|
): Promise<T> {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_ORIGIN}${path}`, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new ApiError(
|
||||||
|
0,
|
||||||
|
"No se pudo conectar con el servidor. Verifica tu conexión.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let message = `Error ${res.status}`;
|
||||||
|
try {
|
||||||
|
const body = await res.json();
|
||||||
|
if (body?.message) message = body.message;
|
||||||
|
} catch {
|
||||||
|
/* ignore non-JSON error bodies */
|
||||||
|
}
|
||||||
|
throw new ApiError(res.status, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function login(email: string, password: string): Promise<AuthUser> {
|
||||||
|
return apiFetch<AuthUser>("/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function me(): Promise<AuthUser> {
|
||||||
|
return apiFetch<AuthUser>("/auth/me");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): Promise<{ success: boolean }> {
|
||||||
|
return apiFetch<{ success: boolean }>("/auth/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStats(): Promise<CustomerStats> {
|
||||||
|
return apiFetch<CustomerStats>("/customers/stats");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerQuery {
|
||||||
|
query?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
line?: BusinessLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCustomers(
|
||||||
|
q: CustomerQuery,
|
||||||
|
): Promise<CustomerListResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q.query) params.set("query", q.query);
|
||||||
|
if (q.page) params.set("page", String(q.page));
|
||||||
|
if (q.pageSize) params.set("pageSize", String(q.pageSize));
|
||||||
|
if (q.line) params.set("line", q.line);
|
||||||
|
const qs = params.toString();
|
||||||
|
return apiFetch<CustomerListResponse>(`/customers${qs ? `?${qs}` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomer(id: string): Promise<CustomerDetail> {
|
||||||
|
return apiFetch<CustomerDetail>(`/customers/${id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Spanish label maps + formatting helpers. Single source of truth for i18n.
|
||||||
|
|
||||||
|
import type { ServiceKind, TransactionDomain } from "./types";
|
||||||
|
|
||||||
|
export const DOMAIN_LABELS: Record<string, string> = {
|
||||||
|
UTILITY: "Servicios",
|
||||||
|
INSURANCE: "Seguros",
|
||||||
|
TRUST: "Fideicomiso",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function domainLabel(domain: TransactionDomain): string {
|
||||||
|
return DOMAIN_LABELS[domain] ?? domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICE_KIND_LABELS: Record<string, string> = {
|
||||||
|
WATER: "Agua",
|
||||||
|
ELECTRIC: "Electricidad",
|
||||||
|
GAS: "Gas",
|
||||||
|
CABLE: "Cable/TV",
|
||||||
|
PROPERTY_TAX: "Predial",
|
||||||
|
FEDERAL_ZONE: "Zona Federal",
|
||||||
|
ALARM: "Alarma",
|
||||||
|
OTHER: "Otro",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serviceKindLabel(kind: ServiceKind): string {
|
||||||
|
return SERVICE_KIND_LABELS[kind] ?? kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A short glyph per service kind, drawn with unicode so no icon dependency.
|
||||||
|
export const SERVICE_KIND_GLYPH: Record<string, string> = {
|
||||||
|
WATER: "≈",
|
||||||
|
ELECTRIC: "⚡",
|
||||||
|
GAS: "◐",
|
||||||
|
CABLE: "▤",
|
||||||
|
PROPERTY_TAX: "⌂",
|
||||||
|
FEDERAL_ZONE: "⇲",
|
||||||
|
ALARM: "◈",
|
||||||
|
OTHER: "•",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serviceKindGlyph(kind: ServiceKind): string {
|
||||||
|
return SERVICE_KIND_GLYPH[kind] ?? "•";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- formatting -----
|
||||||
|
|
||||||
|
export function formatMoney(
|
||||||
|
value: string | number | null | undefined,
|
||||||
|
currency: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "—";
|
||||||
|
const num = typeof value === "string" ? Number(value) : value;
|
||||||
|
if (Number.isNaN(num)) return String(value);
|
||||||
|
const cur = (currency ?? "USD").toUpperCase();
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat("es-MX", {
|
||||||
|
style: "currency",
|
||||||
|
currency: cur,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(num);
|
||||||
|
} catch {
|
||||||
|
// Unknown currency code — fall back to plain number + suffix.
|
||||||
|
return `${num.toLocaleString("es-MX", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})} ${cur}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(
|
||||||
|
iso: string | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return "—";
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||||
|
const yyyy = d.getUTCFullYear();
|
||||||
|
return `${dd}/${mm}/${yyyy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString("es-MX");
|
||||||
|
}
|
||||||
|
|
||||||
|
// "sourceSystem" from legacyRefs → display label.
|
||||||
|
export function sourceSystemLabel(source: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
utilities: "Servicios",
|
||||||
|
insurance: "Seguros",
|
||||||
|
};
|
||||||
|
return map[source] ?? source;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
// TypeScript types for the Jorge Cuadros & Asociados API responses.
|
||||||
|
// Decimals arrive as strings, dates as ISO strings.
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerStats {
|
||||||
|
customers: number;
|
||||||
|
withUtilities: number;
|
||||||
|
withInsurance: number;
|
||||||
|
bothLines: number;
|
||||||
|
policies: number;
|
||||||
|
properties: number;
|
||||||
|
transactions: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessLine = "utility" | "insurance" | "both";
|
||||||
|
|
||||||
|
export interface CustomerListItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
status: boolean;
|
||||||
|
propertyCount: number;
|
||||||
|
policyCount: number;
|
||||||
|
transactionCount: number;
|
||||||
|
hasUtilities: boolean;
|
||||||
|
hasInsurance: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerListResponse {
|
||||||
|
items: CustomerListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
pageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyRef {
|
||||||
|
id: string;
|
||||||
|
sourceSystem: string; // "utilities" | "insurance"
|
||||||
|
sourceTable: string;
|
||||||
|
legacyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceKind =
|
||||||
|
| "WATER"
|
||||||
|
| "ELECTRIC"
|
||||||
|
| "GAS"
|
||||||
|
| "CABLE"
|
||||||
|
| "PROPERTY_TAX"
|
||||||
|
| "FEDERAL_ZONE"
|
||||||
|
| "ALARM"
|
||||||
|
| "OTHER"
|
||||||
|
| string;
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
id: string;
|
||||||
|
kind: ServiceKind;
|
||||||
|
accountNumber: string | null;
|
||||||
|
meterNumber: string | null;
|
||||||
|
route: string | null;
|
||||||
|
dueDay: string | null;
|
||||||
|
active: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustAccount {
|
||||||
|
id: string;
|
||||||
|
bankName: string | null;
|
||||||
|
trustNumber: string | null;
|
||||||
|
bankFee: string | null;
|
||||||
|
dueDate1: string | null;
|
||||||
|
dueDate2: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentRef {
|
||||||
|
id?: string;
|
||||||
|
documentType: string | null;
|
||||||
|
storageKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Property {
|
||||||
|
id: string;
|
||||||
|
addressLine1: string | null;
|
||||||
|
addressLine2: string | null;
|
||||||
|
phone1: string | null;
|
||||||
|
phone2: string | null;
|
||||||
|
phone3: string | null;
|
||||||
|
zone: string | null;
|
||||||
|
services: Service[];
|
||||||
|
trustAccount: TrustAccount | null;
|
||||||
|
documents: DocumentRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Installment {
|
||||||
|
id: string;
|
||||||
|
sequence: number;
|
||||||
|
amount: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
paidDate: string | null;
|
||||||
|
checkNumber: string | null;
|
||||||
|
isCash: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vehicle {
|
||||||
|
id: string;
|
||||||
|
make: string | null;
|
||||||
|
model: string | null;
|
||||||
|
modelYear: number | null;
|
||||||
|
licensePlate: string | null;
|
||||||
|
bodyType: string | null;
|
||||||
|
engineNumber?: string | null;
|
||||||
|
vinNumber?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InsuredDriver {
|
||||||
|
id: string;
|
||||||
|
fullName: string | null;
|
||||||
|
licenseNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Beneficiary {
|
||||||
|
id: string;
|
||||||
|
name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NamedRef {
|
||||||
|
name: string | null;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Policy {
|
||||||
|
id: string;
|
||||||
|
policyNumber: string | null;
|
||||||
|
policyType: NamedRef | null;
|
||||||
|
insuranceProvider: NamedRef | null;
|
||||||
|
agentName: string | null;
|
||||||
|
policyFrom: string | null;
|
||||||
|
policyTo: string | null;
|
||||||
|
netPremium: string | null;
|
||||||
|
total: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
liquidated: boolean;
|
||||||
|
installments: Installment[];
|
||||||
|
vehicles: Vehicle[];
|
||||||
|
insuredDrivers: InsuredDriver[];
|
||||||
|
beneficiaries: Beneficiary[];
|
||||||
|
claims: unknown[];
|
||||||
|
documents: DocumentRef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransactionDomain = "UTILITY" | "INSURANCE" | "TRUST" | string;
|
||||||
|
|
||||||
|
export interface TransactionType {
|
||||||
|
nameEn: string | null;
|
||||||
|
nameEs: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: string;
|
||||||
|
transactionDate: string | null;
|
||||||
|
domain: TransactionDomain;
|
||||||
|
amount: string | null;
|
||||||
|
currency: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
period?: string | null;
|
||||||
|
message: string | null;
|
||||||
|
checkNumber: string | null;
|
||||||
|
type: TransactionType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TransactionSummaryRow {
|
||||||
|
domain: TransactionDomain;
|
||||||
|
currency: string;
|
||||||
|
total: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerDetail {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
addressLine1: string | null;
|
||||||
|
addressLine2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zipCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
mobile: string | null;
|
||||||
|
fax: string | null;
|
||||||
|
email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
identificationType: string | null;
|
||||||
|
identificationNumber: string | null;
|
||||||
|
identificationExpiration: string | null;
|
||||||
|
customerSince: string | null;
|
||||||
|
status: boolean;
|
||||||
|
feeAmount: string | number | null;
|
||||||
|
preferredCurrency: string | null;
|
||||||
|
legacyRefs: LegacyRef[];
|
||||||
|
properties: Property[];
|
||||||
|
policies: Policy[];
|
||||||
|
transactions: Transaction[];
|
||||||
|
transactionSummary: TransactionSummaryRow[];
|
||||||
|
}
|
||||||
+6
-13
@@ -2,19 +2,12 @@ packages:
|
|||||||
- "apps/*"
|
- "apps/*"
|
||||||
- "packages/*"
|
- "packages/*"
|
||||||
|
|
||||||
allowBuilds:
|
|
||||||
'@nestjs/core': set this to true or false
|
|
||||||
'@prisma/client': set this to true or false
|
|
||||||
'@prisma/engines': set this to true or false
|
|
||||||
argon2: set this to true or false
|
|
||||||
prisma: set this to true or false
|
|
||||||
|
|
||||||
# Native / postinstall build scripts we trust (argon2 native addon, Prisma
|
# Native / postinstall build scripts we trust (argon2 native addon, Prisma
|
||||||
# engine download + client generation, Nest core). pnpm blocks build scripts
|
# engine download + client generation, Nest core). pnpm blocks build scripts
|
||||||
# by default; these are required for the API to run.
|
# by default; these are required for the API to run.
|
||||||
onlyBuiltDependencies:
|
allowBuilds:
|
||||||
- "@nestjs/core"
|
'@nestjs/core': true
|
||||||
- "@prisma/client"
|
'@prisma/client': true
|
||||||
- "@prisma/engines"
|
'@prisma/engines': true
|
||||||
- argon2
|
argon2: true
|
||||||
- prisma
|
prisma: true
|
||||||
|
|||||||
Reference in New Issue
Block a user