feat: consolidations, aduanero portal, payments admin, bug fixes, tests

## Bugs corregidos
- bodega/page.tsx: estados correctos §08 (RECIBIDO_BODEGA, EN_VERIFICACION, EN_TRANSITO_ECUADOR)
- admin/page.tsx: carga paquetes reales, alertas de incidencias/B2B/cobros pendientes, barras de estado

## Nuevos portales y páginas
- /aduanero/ — portal propio para AGENTE_ADUANERO (layout + dashboard §11 + declaraciones DSI)
- /aduanero/declaraciones — cola de declaraciones con formulario DSI y detección DAI automática
- /admin/pagos — gestión de cobros: KPIs, filtros por estado, tabla con breakdown
- /portal/consolidacion — cliente crea/gestiona consolidaciones §21
- /bodega/consolidacion — operador cierra y despacha consolidaciones (→ EN_TRANSITO_ECUADOR)

## API nueva (ConsolidationsModule)
- GET/POST /consolidations
- GET /consolidations/:id
- POST /consolidations/:id/packages
- DELETE /consolidations/:id/packages/:packageId
- POST /consolidations/:id/close
- POST /consolidations/:id/dispatch (→ actualiza paquetes a EN_TRANSITO_ECUADOR)

## Prisma schema v0.5
- ConsolidationStatus enum (ABIERTA, CERRADA, DESPACHADA, ENTREGADA, CANCELADA)
- Consolidation model con totales calculados (totalWeightLb, totalValue)
- ConsolidationPackage (tabla intermedia, un paquete = una consolidación)
- db push aplicado a remote DB (46.202.93.92)

## Navegación
- Login: AGENTE_ADUANERO → /aduanero (ya no /bodega)
- Bodega nav: añadido Consolidaciones
- Portal nav: añadido Consolidar
- Admin nav: añadido Cobros

## Tests (104 total, 8 suites)
- payments.service.spec.ts: 16 tests (createIntent, confirm, list, findByPackageForUser)
- notifications.service.spec.ts: 12 tests (getTemplates, updateTemplate, seedDefaultTemplates, notifyStatusChange, findByUser)

## Legal §21
- Registro: aviso LOPDP Ecuador + normativa NJ en footer del formulario
- Landing footer: aviso detallado de protección de datos LOPDP/NJ

## api.ts: consolidations.* client methods
This commit is contained in:
Lizandro Guarnizo
2026-06-01 18:29:26 -05:00
parent 5565eef554
commit 7682b11dff
23 changed files with 2158 additions and 95 deletions
+1
View File
@@ -9,6 +9,7 @@ const NAV = [
{ href: "/admin", icon: "◈", label: "Dashboard" },
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
+172 -45
View File
@@ -3,72 +3,199 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §12 Admin Dashboard — métricas reales de usuarios, paquetes, pagos y B2B
export default function AdminDashboard() {
const [users, setUsers] = useState<any[]>([]);
const [b2b, setB2b] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [b2b, setB2b] = useState<any[]>([]);
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.users.list(), api.b2b.list()])
.then(([u, b]) => { setUsers(u); setB2b(b); })
.catch(() => {}).finally(() => setLoading(false));
Promise.all([
api.users.list(),
api.packages.list(),
api.b2b.list(),
api.payments.list(),
])
.then(([u, p, b, pay]) => { setUsers(u); setPackages(p); setB2b(b); setPayments(pay); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
const clientes = users.filter(u => u.role === "CLIENTE").length;
const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length;
// Métricas
const clientes = users.filter(u => u.role === "CLIENTE").length;
const activos = users.filter(u => u.isActive).length;
const b2bPending = b2b.filter(r => r.status === "PENDIENTE").length;
const incidencias = packages.filter(p => p.status === "INCIDENCIA").length;
const enTransito = packages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length;
const entregados = packages.filter(p => p.status === "ENTREGADO").length;
const pendientePago = packages.filter(p => p.status === "VERIFICADO" && !p.paidAt).length;
const ingresoTotal = payments
.filter(p => p.status === "COMPLETADO")
.reduce((s: number, p: any) => s + Number(p.amount), 0);
// Paquetes por estado para mini-gráfico
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Admin</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard Admin</h1>
<p className="dash-page-subtitle">Vista ejecutiva ingresos, operaciones y alertas del sistema.</p>
</div>
{/* Alertas críticas */}
{(incidencias > 0 || b2bPending > 0 || pendientePago > 0) && (
<div style={{ display: "flex", flexWrap: "wrap", gap: ".75rem", marginBottom: "1.5rem" }}>
{incidencias > 0 && (
<Link href="/admin/reportes" style={{
display: "flex", alignItems: "center", gap: ".5rem", textDecoration: "none",
background: "#FEF2F2", border: "1px solid #FCA5A5", borderRadius: 8,
padding: ".625rem 1rem", fontSize: ".85rem", color: "#991B1B", fontWeight: 600,
}}>
{incidencias} incidencia{incidencias !== 1 ? "s" : ""} abiertas
</Link>
)}
{b2bPending > 0 && (
<div style={{
display: "flex", alignItems: "center", gap: ".5rem",
background: "#FFFBEB", border: "1px solid #FCD34D", borderRadius: 8,
padding: ".625rem 1rem", fontSize: ".85rem", color: "#92400E", fontWeight: 600,
}}>
📋 {b2bPending} solicitud{b2bPending !== 1 ? "es" : ""} B2B pendiente{b2bPending !== 1 ? "s" : ""}
</div>
)}
{pendientePago > 0 && (
<Link href="/admin/pagos" style={{
display: "flex", alignItems: "center", gap: ".5rem", textDecoration: "none",
background: "#EFF6FF", border: "1px solid #93C5FD", borderRadius: 8,
padding: ".625rem 1rem", fontSize: ".85rem", color: "#1E40AF", fontWeight: 600,
}}>
💳 {pendientePago} paquete{pendientePago !== 1 ? "s" : ""} pendiente{pendientePago !== 1 ? "s" : ""} de pago
</Link>
)}
</div>
)}
{/* KPIs principales */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: clientes, color: "var(--green)" },
{ label: "Solicitudes B2B", value: b2bPending, color: "var(--yellow)" },
{ label: "Activos", value: users.filter(u=>u.isActive).length, color: "var(--accent)" },
{ label: "Ingresos cobrados", value: `$${ingresoTotal.toFixed(2)}`, color: "var(--green)", icon: "💰" },
{ label: "Total paquetes", value: packages.length, color: "var(--primary)", icon: "📦" },
{ label: "Entregados", value: entregados, color: "var(--green)", icon: "✅" },
{ label: "En tránsito a EC", value: enTransito, color: "var(--accent)", icon: "✈️" },
{ label: "Clientes activos", value: clientes, color: "var(--primary)", icon: "👤" },
{ label: "Usuarios totales", value: activos, color: "var(--gray-600)", icon: "👥" },
{ label: "Incidencias", value: incidencias, color: "var(--red)", icon: "⚠️" },
{ label: "Pendiente de pago", value: pendientePago, color: "var(--yellow)", icon: "💳" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div style={{ fontSize: "1.25rem", marginBottom: ".25rem" }}>{s.icon}</div>
<div className="stat-value" style={{ color: s.color, fontSize: "1.6rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Distribución de paquetes por estado */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Últimos usuarios</span>
<Link href="/admin/usuarios" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Paquetes por estado</span>
<Link href="/admin/reportes" className="btn btn-ghost" style={{ fontSize: ".8rem", padding: ".25rem .6rem" }}>Reportes </Link>
</div>
<div className="table-wrap" style={{ borderRadius: 0, border: "none" }}>
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Rol</th><th>Estado</th></tr></thead>
<tbody>
{users.slice(0,8).map(u => (
<tr key={u.id}>
<td className="font-medium">{u.firstName} {u.lastName}</td>
<td className="text-sm text-muted">{u.email}</td>
<td><span className="badge badge-blue">{u.role}</span></td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-gray"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
</tr>
))}
</tbody>
</table>
<div className="card-body">
{[
{ status: "RECIBIDO_BODEGA", label: "Recibidos en bodega", color: "#3B82F6" },
{ status: "EN_VERIFICACION", label: "En verificación", color: "#F59E0B" },
{ status: "VERIFICADO", label: "Verificados", color: "#10B981" },
{ status: "DECLARACION_ADUANERA", label: "Declaración aduanera", color: "#0057FF" },
{ status: "EN_TRANSITO_ECUADOR", label: "En tránsito a Ecuador", color: "#F97316" },
{ status: "EN_ADUANA_ECUADOR", label: "En aduana Ecuador", color: "#EF4444" },
{ status: "ENTREGADO", label: "Entregados", color: "#10B981" },
{ status: "INCIDENCIA", label: "Incidencias", color: "#EF4444" },
].map(({ status, label, color }) => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".6rem" }}>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: ".15rem" }}>
<span style={{ fontSize: ".8rem", color: "var(--gray-600)" }}>{label}</span>
<span style={{ fontSize: ".8rem", fontWeight: 700 }}>{count}</span>
</div>
<div style={{ height: 4, background: "var(--gray-100)", borderRadius: 999 }}>
<div style={{ height: 4, borderRadius: 999, background: color, width: `${pct}%`, transition: "width .4s" }} />
</div>
</div>
);
})}
{packages.length === 0 && <p style={{ color: "var(--gray-400)", fontSize: ".875rem" }}>Sin paquetes aún.</p>}
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Solicitudes B2B pendientes</span></div>
{b2bPending === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin solicitudes pendientes.</p>
) : (
b2b.filter(r=>r.status==="PENDIENTE").map(r => (
<div key={r.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{r.companyName}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{r.contactEmail}</div>
</div>
))
)}
{/* Panel derecho */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Usuarios recientes */}
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Usuarios recientes</span>
<Link href="/admin/usuarios" className="btn btn-ghost" style={{ fontSize: ".8rem", padding: ".25rem .6rem" }}>Ver todos </Link>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead><tr><th>Nombre</th><th>Rol</th><th>Estado</th></tr></thead>
<tbody>
{users.slice(0, 5).map(u => (
<tr key={u.id}>
<td style={{ fontSize: ".82rem" }}>
<div style={{ fontWeight: 600 }}>{u.firstName} {u.lastName}</div>
<div style={{ color: "var(--gray-400)", fontSize: ".75rem" }}>{u.email}</div>
</td>
<td><span className="badge badge-blue" style={{ fontSize: ".7rem" }}>{u.role}</span></td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-gray"}`} style={{ fontSize: ".7rem" }}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Acciones rápidas */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Acciones rápidas</span></div>
<div className="card-body" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".5rem" }}>
{[
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
{ href: "/admin/notificaciones", icon: "📋", label: "Plantillas" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
].map(a => (
<Link key={a.href} href={a.href}
style={{
display: "flex", alignItems: "center", gap: ".5rem",
padding: ".625rem .75rem", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", background: "var(--gray-50)",
fontSize: ".82rem", fontWeight: 500, transition: "background .15s",
}}
onMouseEnter={e => { e.currentTarget.style.background = "var(--gray-100)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "var(--gray-50)"; }}
>
<span>{a.icon}</span>{a.label}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// Admin — Gestión de cobros / pagos del tenant
const STATUS_LABEL: Record<string, string> = {
PENDIENTE: "Pendiente",
PROCESANDO: "Procesando",
COMPLETADO: "Completado",
FALLIDO: "Fallido",
REEMBOLSADO: "Reembolsado",
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow",
PROCESANDO: "badge-blue",
COMPLETADO: "badge-green",
FALLIDO: "badge-red",
REEMBOLSADO: "badge-gray",
};
export default function AdminPagosPage() {
const [payments, setPayments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<string>("ALL");
const [search, setSearch] = useState("");
const load = () => {
setLoading(true);
api.payments.list()
.then(setPayments)
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const filtered = payments.filter(p => {
const matchStatus = filter === "ALL" || p.status === filter;
const matchSearch = !search ||
p.package?.trackingId?.toLowerCase().includes(search.toLowerCase()) ||
p.providerRef?.toLowerCase().includes(search.toLowerCase());
return matchStatus && matchSearch;
});
// Métricas
const total = payments.reduce((s, p) => s + Number(p.amount), 0);
const cobrado = payments.filter(p => p.status === "COMPLETADO").reduce((s, p) => s + Number(p.amount), 0);
const pendiente = payments.filter(p => p.status === "PENDIENTE").reduce((s, p) => s + Number(p.amount), 0);
const fallidos = payments.filter(p => p.status === "FALLIDO").length;
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Gestión de Cobros</h1>
<p className="dash-page-subtitle">Historial de pagos vinculados a paquetes del tenant.</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total facturado", value: `$${total.toFixed(2)}`, color: "var(--primary)" },
{ label: "Cobrado", value: `$${cobrado.toFixed(2)}`, color: "var(--green)" },
{ label: "Pendiente cobro", value: `$${pendiente.toFixed(2)}`,color: "var(--yellow)" },
{ label: "Pagos fallidos", value: fallidos, color: "var(--red)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
{/* Filtros */}
<div style={{ display: "flex", gap: "1rem", marginBottom: "1.25rem", flexWrap: "wrap", alignItems: "center" }}>
<input
className="form-input"
style={{ maxWidth: 260 }}
placeholder="Buscar por tracking o ref. pago..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<div style={{ display: "flex", gap: ".5rem" }}>
{["ALL", "PENDIENTE", "COMPLETADO", "FALLIDO", "REEMBOLSADO"].map(s => (
<button
key={s}
className={`btn ${filter === s ? "btn-primary" : "btn-ghost"}`}
style={{ fontSize: ".8rem", padding: ".35rem .75rem" }}
onClick={() => setFilter(s)}
>
{s === "ALL" ? "Todos" : STATUS_LABEL[s]}
</button>
))}
</div>
<span style={{ marginLeft: "auto", fontSize: ".85rem", color: "var(--gray-500)" }}>
{filtered.length} registro{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{/* Tabla */}
<div className="card">
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead>
<tr>
<th>Tracking</th>
<th>Descripción</th>
<th>Proveedor</th>
<th>Referencia</th>
<th>Monto</th>
<th>Estado</th>
<th>Fecha</th>
</tr>
</thead>
<tbody>
{filtered.map(p => (
<tr key={p.id}>
<td style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".82rem" }}>
{p.package?.trackingId ?? "—"}
</td>
<td style={{ fontSize: ".8rem", color: "var(--gray-600)", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.package?.description ?? "—"}
</td>
<td style={{ fontSize: ".82rem", textTransform: "capitalize" }}>{p.provider}</td>
<td style={{ fontSize: ".78rem", fontFamily: "monospace", color: "var(--gray-500)" }}>
{p.providerRef ?? "—"}
</td>
<td style={{ fontWeight: 700, fontSize: ".9rem" }}>${Number(p.amount).toFixed(2)}</td>
<td>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`} style={{ fontSize: ".7rem" }}>
{STATUS_LABEL[p.status] ?? p.status}
</span>
</td>
<td style={{ fontSize: ".78rem", color: "var(--gray-500)" }}>
{p.paidAt
? new Date(p.paidAt).toLocaleString("es-EC")
: new Date(p.createdAt).toLocaleDateString("es-EC")}
</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: "center", color: "var(--gray-400)", padding: "3rem" }}>
Sin registros de pago.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
@@ -0,0 +1,211 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
// §11 — Declaraciones SENAE desde el portal del agente aduanero
// Misma lógica que /bodega/declaraciones pero accesible solo para AGENTE_ADUANERO
const CATEGORIES = [
{ value: "REGIMEN_4X4", label: "Régimen 4×4 — 0% (≤$400, ≤4kg)" },
{ value: "CATEGORIA_B", label: "Categoría B — 10% (bienes generales)" },
{ value: "CATEGORIA_C", label: "Categoría C — 20% (textiles, calzado, hogar)" },
{ value: "CATEGORIA_D", label: "Categoría D — 015% (electrónicos)" },
];
function autoCategory(pkg: any): string {
const value = parseFloat(pkg.declaredValue ?? 0);
const weightKg = parseFloat(pkg.actualWeight ?? pkg.declaredWeight ?? 0) * 0.453592;
if (value <= 400 && weightKg <= 4) return "REGIMEN_4X4";
return "CATEGORIA_B";
}
export default function AduaneroDeclaracionesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<any | null>(null);
const [form, setForm] = useState({ category: "REGIMEN_4X4", agentNotes: "" });
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const load = () => {
setLoading(true);
api.packages.pendingDeclaration()
.then(setPackages)
.catch(() => {})
.finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const handleSelect = (pkg: any) => {
setSelected(pkg);
setForm({ category: autoCategory(pkg), agentNotes: "" });
setMsg(null);
};
const handleDeclare = async (e: React.FormEvent) => {
e.preventDefault();
if (!selected) return;
setSubmitting(true);
setMsg(null);
try {
const result = await api.packages.senaeDeclare(selected.id, form);
setMsg({ type: "success", text: `DSI generada. N° Autorización SENAE: ${result.authNumber ?? result.senaeAuthNumber ?? "STUB-" + Date.now()}` });
setSelected(null);
load();
} catch (err: any) {
setMsg({ type: "error", text: err.message ?? "Error al generar declaración" });
} finally {
setSubmitting(false);
}
};
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Declaraciones SENAE</h1>
<p className="dash-page-subtitle">
Cola de paquetes verificados pendientes de DSI. Genera la Declaración Simplificada (DSI) o inicia el proceso formal (DAI). §11
</p>
</div>
{msg && (
<div style={{
padding: ".875rem 1.25rem", borderRadius: 8, marginBottom: "1.25rem",
background: msg.type === "success" ? "#F0FDF4" : "#FEF2F2",
border: `1px solid ${msg.type === "success" ? "#86EFAC" : "#FCA5A5"}`,
color: msg.type === "success" ? "#166534" : "#991B1B",
fontWeight: 600, fontSize: ".875rem",
}}>
{msg.type === "success" ? "✅ " : "❌ "}{msg.text}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Cola */}
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Pendientes de declaración</span>
<span className="badge badge-orange">{packages.length}</span>
</div>
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "2rem" }}><div className="spinner" /></div>
) : packages.length === 0 ? (
<div style={{ padding: "2.5rem", textAlign: "center", color: "var(--gray-400)" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>🎉</div>
<p style={{ fontSize: ".9rem" }}>No hay paquetes pendientes de declaración.</p>
<p style={{ fontSize: ".8rem", color: "var(--gray-300)", marginTop: ".25rem" }}>
Los paquetes en estado VERIFICADO aparecerán aquí.
</p>
</div>
) : (
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead>
<tr><th>Tracking</th><th>Valor</th><th>Peso</th><th>Régimen</th></tr>
</thead>
<tbody>
{packages.map(p => {
const supera4x4 = parseFloat(p.declaredValue ?? 0) > 400;
return (
<tr
key={p.id}
onClick={() => handleSelect(p)}
style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50, #EFF6FF)" : undefined }}
>
<td style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".82rem" }}>{p.trackingId}</td>
<td style={{ fontWeight: 600, color: supera4x4 ? "var(--red)" : "var(--green)", fontSize: ".82rem" }}>
${p.declaredValue}
</td>
<td style={{ fontSize: ".82rem" }}>{p.actualWeight ? `${p.actualWeight}lb` : "—"}</td>
<td>
<span className={`badge ${supera4x4 ? "badge-red" : "badge-green"}`} style={{ fontSize: ".7rem" }}>
{supera4x4 ? "DAI" : "4×4"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Formulario DSI */}
{selected ? (
<div className="card">
<div className="card-header">
<span style={{ fontWeight: 600 }}>Generar DSI {selected.trackingId}</span>
</div>
<div className="card-body">
{/* Resumen */}
<div style={{ background: "var(--gray-50)", borderRadius: 8, padding: "1rem", marginBottom: "1rem", fontSize: ".85rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".5rem" }}>
<div><strong>Descripción:</strong> {selected.description}</div>
<div><strong>Tienda:</strong> {selected.store ?? "—"}</div>
<div>
<strong>Valor:</strong>{" "}
<span style={{ color: parseFloat(selected.declaredValue) > 400 ? "var(--red)" : "var(--green)", fontWeight: 700 }}>
${selected.declaredValue}
</span>
</div>
<div><strong>Peso real:</strong> {selected.actualWeight ? `${selected.actualWeight}lb` : selected.declaredWeight ? `~${selected.declaredWeight}lb` : "—"}</div>
<div><strong>Cliente:</strong> {selected.user?.firstName ?? "—"} {selected.user?.lastName ?? ""}</div>
</div>
{parseFloat(selected.declaredValue ?? 0) > 400 && (
<div style={{ marginTop: ".75rem", padding: ".625rem .875rem", background: "#FFF7ED", borderRadius: 6, borderLeft: "3px solid #F97316", fontSize: ".8rem", color: "#9A3412" }}>
<strong>Supera $400 proceso DAI obligatorio.</strong> Coordinar con importador.
</div>
)}
</div>
<form onSubmit={handleDeclare} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Categoría SENAE *
</label>
<select
className="form-input"
value={form.category}
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}
>
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Notas del agente
</label>
<textarea
className="form-input"
rows={3}
value={form.agentNotes}
onChange={e => setForm(f => ({ ...f, agentNotes: e.target.value }))}
placeholder="Partida arancelaria, observaciones, documentación adicional..."
style={{ resize: "vertical" }}
/>
</div>
<div style={{ display: "flex", gap: ".75rem" }}>
<button type="submit" className="btn btn-primary" disabled={submitting} style={{ flex: 1 }}>
{submitting ? "Generando DSI…" : "🛃 Generar y enviar a SENAE"}
</button>
<button type="button" className="btn btn-ghost" onClick={() => setSelected(null)}>
Cancelar
</button>
</div>
</form>
</div>
</div>
) : (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".75rem" }}>👈</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>
Selecciona un paquete de la lista para generar su declaración DSI.
</p>
</div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth, api } from "@/lib/api";
// §11 — Portal exclusivo del Agente Aduanero
// Solo AGENTE_ADUANERO, ADMIN_EMPRESA y SUPER_ADMIN pueden acceder
const NAV = [
{ href: "/aduanero", icon: "◈", label: "Dashboard" },
{ href: "/aduanero/declaraciones", icon: "🛃", label: "Declaraciones DSI" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
const ALLOWED = ["AGENTE_ADUANERO", "ADMIN_EMPRESA", "SUPER_ADMIN"];
export default function AduaneroLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
if (!ALLOWED.includes(u.role)) { router.replace("/portal"); return; }
setUser(u);
}, [router]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
clearAuth();
router.push("/login");
};
if (!user) return <div className="loading-overlay"><div className="spinner" /></div>;
return (
<div className="dash-layout">
<aside className="dash-sidebar">
<div className="dash-logo" style={{ color: "#F59E0B" }}>Aduana<span style={{ color: "white" }}>EC</span></div>
<nav className="dash-nav">
{NAV.map(item => (
<Link
key={item.href}
href={item.href}
className={`dash-nav-item ${pathname === item.href || (item.href !== "/aduanero" && pathname.startsWith(item.href)) ? "active" : ""}`}
>
<span>{item.icon}</span>
<span>{item.label}</span>
</Link>
))}
</nav>
<div className="dash-user">
<div className="dash-user-name">{user.firstName} {user.lastName}</div>
<div className="dash-user-role" style={{ color: "#F59E0B" }}>{user.role}</div>
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: ".5rem", color: "rgba(255,255,255,.5)", fontSize: ".8rem" }}
onClick={handleLogout}
>
Cerrar sesión
</button>
</div>
</aside>
<div className="dash-main">
<header className="dash-topbar">
<span style={{ fontSize: "1rem", fontWeight: 600 }}>Portal Agente Aduanero</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>{user.email}</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §11 — Dashboard del Agente Aduanero
// Ve la cola de paquetes VERIFICADOS pendientes de DSI, métricas y acciones rápidas
export default function AduaneroDashboard() {
const [pending, setPending] = useState<any[]>([]);
const [allPackages, setAllPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.packages.pendingDeclaration(),
api.packages.list(),
])
.then(([pend, all]) => { setPending(pend); setAllPackages(all); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
const declaradas = allPackages.filter(p => p.status === "DECLARACION_ADUANERA").length;
const enAduana = allPackages.filter(p => p.status === "EN_ADUANA_ECUADOR").length;
const enTransito = allPackages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length;
const entregadas = allPackages.filter(p => p.status === "ENTREGADO").length;
// Paquetes que superan el límite 4×4 (requieren DAI)
const requierenDAI = pending.filter(p => parseFloat(p.declaredValue ?? 0) > 400);
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard Agente Aduanero</h1>
<p className="dash-page-subtitle">Cola de declaraciones SENAE DSI simplificada y DAI formal. §11</p>
</div>
{/* Alerta si hay paquetes DAI pendientes */}
{requierenDAI.length > 0 && (
<div style={{
background: "#FFFBEB", border: "1px solid #FCD34D", borderRadius: 8,
padding: ".875rem 1.25rem", marginBottom: "1.5rem",
display: "flex", alignItems: "center", gap: ".75rem",
fontSize: ".875rem", color: "#92400E",
}}>
<span style={{ fontSize: "1.2rem" }}></span>
<span>
<strong>{requierenDAI.length}</strong> paquete{requierenDAI.length !== 1 ? "s" : ""} supera{requierenDAI.length === 1 ? "" : "n"} el límite 4×4 ($400) requiere{requierenDAI.length === 1 ? "" : "n"} <strong>importación formal (DAI)</strong>.
</span>
</div>
)}
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Pendientes de DSI", value: pending.length, color: "#F59E0B", icon: "⏳" },
{ label: "Requieren DAI", value: requierenDAI.length, color: "var(--red)", icon: "⚠️" },
{ label: "Con DSI aprobada", value: declaradas, color: "var(--primary)", icon: "✅" },
{ label: "En aduana Ecuador", value: enAduana, color: "#8B5CF6", icon: "🏛️" },
{ label: "En tránsito", value: enTransito, color: "var(--accent)", icon: "✈️" },
{ label: "Entregados", value: entregadas, color: "var(--green)", icon: "📬" },
{ label: "Total en sistema", value: allPackages.length, color: "var(--gray-600)", icon: "📦" },
{ label: "Pendiente hoy", value: pending.filter(p => {
const d = new Date(p.createdAt ?? 0);
const today = new Date();
return d.toDateString() === today.toDateString();
}).length, color: "#0057FF", icon: "📅" },
].map(s => (
<div key={s.label} className="stat-card">
<div style={{ fontSize: "1.25rem", marginBottom: ".25rem" }}>{s.icon}</div>
<div className="stat-value" style={{ color: s.color, fontSize: "1.6rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Cola de pendientes */}
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Cola de declaraciones pendientes</span>
<Link href="/aduanero/declaraciones" className="btn btn-primary" style={{ fontSize: ".8rem", padding: ".35rem .75rem" }}>
Ir a declaraciones
</Link>
</div>
{pending.length === 0 ? (
<div style={{ padding: "2.5rem", textAlign: "center", color: "var(--gray-400)" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>🎉</div>
<p style={{ fontSize: ".9rem" }}>Cola vacía todos los paquetes están declarados.</p>
</div>
) : (
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead>
<tr>
<th>Tracking</th>
<th>Descripción</th>
<th>Valor</th>
<th>Peso</th>
<th>Régimen</th>
</tr>
</thead>
<tbody>
{pending.slice(0, 10).map(p => {
const supera4x4 = parseFloat(p.declaredValue ?? 0) > 400;
return (
<tr key={p.id}>
<td style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".82rem" }}>{p.trackingId}</td>
<td style={{ fontSize: ".8rem", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.description ?? "—"}
</td>
<td style={{ fontSize: ".82rem", fontWeight: 600, color: supera4x4 ? "var(--red)" : "var(--green)" }}>
${p.declaredValue}
</td>
<td style={{ fontSize: ".82rem" }}>
{p.actualWeight ? `${p.actualWeight}lb` : "—"}
</td>
<td>
<span className={`badge ${supera4x4 ? "badge-red" : "badge-green"}`} style={{ fontSize: ".7rem" }}>
{supera4x4 ? "DAI" : "4×4"}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
{pending.length > 10 && (
<div style={{ padding: ".75rem 1.25rem", fontSize: ".8rem", color: "var(--gray-400)", textAlign: "center" }}>
y {pending.length - 10} más
</div>
)}
</div>
)}
</div>
{/* Info SENAE + acciones */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Resumen de regímenes */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Resumen por régimen aduanero (§15)</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".6rem" }}>
{[
{ label: "Régimen 4×4 (DSI simplificada)", desc: "≤$400 · ≤4kg · 0% arancel", count: pending.filter(p => parseFloat(p.declaredValue ?? 0) <= 400).length, color: "var(--green)" },
{ label: "Importación formal (DAI)", desc: ">$400 o >4kg — aranceles SENAE", count: requierenDAI.length, color: "var(--red)" },
].map(r => (
<div key={r.label} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: ".5rem .75rem", background: "var(--gray-50)", borderRadius: 8 }}>
<div>
<div style={{ fontWeight: 600, fontSize: ".85rem" }}>{r.label}</div>
<div style={{ fontSize: ".75rem", color: "var(--gray-500)" }}>{r.desc}</div>
</div>
<span style={{ fontWeight: 700, fontSize: "1.25rem", color: r.color }}>{r.count}</span>
</div>
))}
</div>
</div>
{/* Acciones */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Acciones</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
<Link href="/aduanero/declaraciones"
style={{
display: "flex", alignItems: "center", gap: ".75rem", padding: ".75rem 1rem",
background: "#F59E0B", borderRadius: 8, textDecoration: "none", color: "#fff",
fontWeight: 700, fontSize: ".9rem",
}}
>
<span>🛃</span> Ir a cola de declaraciones
</Link>
<Link href="/bodega/despacho"
style={{
display: "flex", alignItems: "center", gap: ".75rem", padding: ".625rem 1rem",
background: "var(--gray-50)", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", fontSize: ".875rem", fontWeight: 500,
}}
>
<span></span> Ver despachos
</Link>
<Link href="/admin/auditoria"
style={{
display: "flex", alignItems: "center", gap: ".75rem", padding: ".625rem 1rem",
background: "var(--gray-50)", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", fontSize: ".875rem", fontWeight: 500,
}}
>
<span>🔍</span> Logs de auditoría
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,250 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
// §21 — Bodega: gestión de consolidaciones
// El operador ve todas las consolidaciones, puede cerrarlas y despacharlas
const STATUS_BADGE: Record<string, string> = {
ABIERTA: "badge-blue",
CERRADA: "badge-yellow",
DESPACHADA: "badge-orange",
ENTREGADA: "badge-green",
CANCELADA: "badge-gray",
};
const STATUS_LABEL: Record<string, string> = {
ABIERTA: "Abierta",
CERRADA: "Cerrada — lista para despacho",
DESPACHADA: "Despachada",
ENTREGADA: "Entregada",
CANCELADA: "Cancelada",
};
export default function BodegaConsolidacionPage() {
const [consolidations, setConsolidations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState<string | null>(null);
const [modal, setModal] = useState<{ id: string; action: "close" | "dispatch" } | null>(null);
const [courierTracking, setCourierTracking] = useState("");
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<string | null>(null);
const [filter, setFilter] = useState("ALL");
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 3500); };
const load = useCallback(async () => {
setLoading(true);
try {
const data = await api.consolidations.list();
setConsolidations(data);
} catch {
setConsolidations([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = consolidations.filter(c => filter === "ALL" || c.status === filter);
const handleAction = async () => {
if (!modal) return;
setSubmitting(true);
try {
if (modal.action === "close") {
await api.consolidations.close(modal.id, courierTracking || undefined);
showToast("Consolidación cerrada y lista para despacho.");
} else {
if (!courierTracking.trim()) { showToast("Ingresa el número de tracking del courier."); setSubmitting(false); return; }
await api.consolidations.dispatch(modal.id, courierTracking);
showToast("Consolidación despachada. Paquetes actualizados a EN_TRANSITO_ECUADOR.");
}
setModal(null);
setCourierTracking("");
await load();
} catch (e: any) {
showToast(e?.message ?? "Error.");
} finally {
setSubmitting(false);
}
};
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
// KPIs
const abiertas = consolidations.filter(c => c.status === "ABIERTA").length;
const cerradas = consolidations.filter(c => c.status === "CERRADA").length;
const despachadas = consolidations.filter(c => c.status === "DESPACHADA").length;
return (
<div>
{toast && (
<div style={{
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
background: "var(--primary)", color: "#fff", borderRadius: 8,
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".875rem",
boxShadow: "0 4px 16px rgba(0,0,0,.2)",
}}>{toast}</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Consolidaciones</h1>
<p className="dash-page-subtitle">Gestión de envíos consolidados de clientes. §21</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Abiertas", value: abiertas, color: "var(--primary)" },
{ label: "Listas despacho", value: cerradas, color: "var(--yellow)" },
{ label: "Despachadas", value: despachadas, color: "var(--accent)" },
{ label: "Total", value: consolidations.length, color: "var(--gray-600)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
{/* Filtros */}
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.25rem", flexWrap: "wrap" }}>
{["ALL", "ABIERTA", "CERRADA", "DESPACHADA", "ENTREGADA"].map(s => (
<button
key={s}
className={`btn ${filter === s ? "btn-primary" : "btn-ghost"}`}
style={{ fontSize: ".8rem", padding: ".35rem .75rem" }}
onClick={() => setFilter(s)}
>
{s === "ALL" ? "Todas" : STATUS_LABEL[s] ?? s}
</button>
))}
</div>
{/* Lista */}
{filtered.length === 0 ? (
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".5rem" }}>📦</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>Sin consolidaciones.</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{filtered.map(c => (
<div key={c.id} className="card">
<div
style={{ padding: "1rem 1.25rem", cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: ".75rem" }}
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
>
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<div>
<span style={{ fontWeight: 700, color: "var(--primary)" }}>{c.code}</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "badge-gray"}`} style={{ marginLeft: ".75rem", fontSize: ".7rem" }}>{c.status}</span>
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>
{c.packages?.length ?? 0} paquete{c.packages?.length !== 1 ? "s" : ""} ·
{c.totalWeightLb ? ` ${c.totalWeightLb}lb` : ""} ·
{c.totalValue ? ` $${c.totalValue}` : ""}
</div>
</div>
<div style={{ display: "flex", gap: ".5rem" }}>
{c.status === "ABIERTA" && (
<button
className="btn btn-ghost"
style={{ fontSize: ".8rem", padding: ".35rem .7rem" }}
onClick={e => { e.stopPropagation(); setModal({ id: c.id, action: "close" }); setCourierTracking(""); }}
>
Cerrar consolidación
</button>
)}
{c.status === "CERRADA" && (
<button
className="btn btn-primary"
style={{ fontSize: ".8rem", padding: ".35rem .7rem" }}
onClick={e => { e.stopPropagation(); setModal({ id: c.id, action: "dispatch" }); setCourierTracking(""); }}
>
Despachar
</button>
)}
</div>
</div>
{expanded === c.id && (
<div style={{ borderTop: "1px solid var(--gray-100)", padding: "1rem 1.25rem" }}>
{c.notes && <p style={{ fontSize: ".82rem", color: "var(--gray-500)", marginBottom: ".75rem" }}>Notas: {c.notes}</p>}
{c.courierTracking && (
<p style={{ fontSize: ".82rem", color: "var(--gray-600)", marginBottom: ".75rem" }}>
Courier tracking: <code style={{ fontWeight: 700 }}>{c.courierTracking}</code>
</p>
)}
<div className="table-wrap" style={{ border: "none" }}>
<table>
<thead><tr><th>Tracking</th><th>Descripción</th><th>Valor</th><th>Peso</th><th>Estado</th></tr></thead>
<tbody>
{c.packages?.map((cp: any) => (
<tr key={cp.id}>
<td style={{ fontWeight: 700, fontSize: ".82rem", color: "var(--primary)" }}>{cp.package?.trackingId}</td>
<td style={{ fontSize: ".8rem" }}>{cp.package?.description ?? "—"}</td>
<td style={{ fontSize: ".82rem" }}>${cp.package?.declaredValue}</td>
<td style={{ fontSize: ".82rem" }}>{cp.package?.actualWeight ?? "—"}lb</td>
<td><span className="badge badge-blue" style={{ fontSize: ".7rem" }}>{cp.package?.status?.replace(/_/g," ")}</span></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Modal cerrar / despachar */}
{modal && (
<div className="modal-overlay" onClick={() => setModal(null)}>
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: 440 }}>
<div className="modal-header">
<span style={{ fontWeight: 700 }}>
{modal.action === "close" ? "Cerrar consolidación" : "Despachar consolidación"}
</span>
<button className="btn btn-ghost" style={{ padding: ".25rem .5rem" }} onClick={() => setModal(null)}></button>
</div>
<div className="modal-body">
<p style={{ fontSize: ".875rem", color: "var(--gray-600)", marginBottom: "1rem" }}>
{modal.action === "close"
? "Cierra la consolidación cuando todos los paquetes estén listos. No se podrán agregar más paquetes."
: "Al despachar, todos los paquetes de esta consolidación pasarán a estado EN_TRANSITO_ECUADOR."}
</p>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Número de tracking courier {modal.action === "dispatch" ? "*" : "(opcional)"}
</label>
<input
className="form-input"
value={courierTracking}
onChange={e => setCourierTracking(e.target.value)}
placeholder="Ej: 7489234823 (FedEx / DHL / UPS)"
/>
</div>
<div className="modal-footer">
<button className="btn btn-ghost" onClick={() => setModal(null)}>Cancelar</button>
<button
className={`btn ${modal.action === "dispatch" ? "btn-primary" : "btn-ghost"}`}
style={modal.action === "close" ? { background: "var(--yellow)", color: "#000" } : {}}
onClick={handleAction}
disabled={submitting}
>
{submitting ? "..." : modal.action === "close" ? "Cerrar consolidación" : "✈️ Confirmar despacho"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+7 -6
View File
@@ -6,12 +6,13 @@ import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/bodega", icon: "◈", label: "Dashboard" },
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación" },
{ href: "/bodega/declaraciones", icon: "🛃", label: "Declaraciones SENAE" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
{ href: "/bodega", icon: "◈", label: "Dashboard" },
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación" },
{ href: "/bodega/declaraciones", icon: "🛃", label: "Declaraciones SENAE" },
{ href: "/bodega/consolidacion", icon: "🗃️", label: "Consolidaciones" },
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
];
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN"];
+130 -36
View File
@@ -3,71 +3,165 @@ import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
// §08 — estados que interesan al operador de bodega NJ
const STATUS_BADGE: Record<string, string> = {
REGISTRADO: "badge-gray",
EN_TRANSITO_BODEGA: "badge-yellow",
RECIBIDO_BODEGA: "badge-blue",
EN_VERIFICACION: "badge-yellow",
VERIFICADO: "badge-green",
DECLARACION_ADUANERA: "badge-blue",
EN_TRANSITO_ECUADOR: "badge-orange",
EN_ADUANA_ECUADOR: "badge-red",
LISTO_ENTREGA: "badge-green",
ENTREGADO: "badge-green",
INCIDENCIA: "badge-red",
};
export default function BodegaDashboard() {
const [packages, setPackages] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [preAlerts, setPreAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.packages.list(), api.preAlerts.list()])
.then(([p, a]) => { setPackages(p); setPreAlerts(a); })
.catch(() => {}).finally(() => setLoading(false));
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
const recibidos = packages.filter(p => p.status === "RECIBIDO_EN_NJ").length;
const enCamino = packages.filter(p => p.status === "EN_CAMINO_A_ECUADOR").length;
const listos = packages.filter(p => p.status === "LISTO_PARA_RETIRO").length;
const alertasPend = preAlerts.filter(p => p.status === "PENDIENTE").length;
// KPIs con estados §08 correctos
const recibidos = packages.filter(p => p.status === "RECIBIDO_BODEGA").length;
const enVerif = packages.filter(p => p.status === "EN_VERIFICACION").length;
const verificados = packages.filter(p => p.status === "VERIFICADO").length;
const enTransito = packages.filter(p => p.status === "EN_TRANSITO_ECUADOR").length;
const incidencias = packages.filter(p => p.status === "INCIDENCIA").length;
const alertasPend = preAlerts.filter(p => p.status === "PENDIENTE").length;
return (
<div>
<div className="mb-6"><h1 className="dash-page-title">Dashboard Bodega</h1></div>
<div className="grid-4" style={{ marginBottom: "2rem" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard Bodega NJ</h1>
<p className="dash-page-subtitle">Operaciones en tiempo real 150 N Day St, City of Orange, NJ 07050</p>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Recibidos en NJ", value: recibidos, color: "var(--primary)" },
{ label: "En camino a EC", value: enCamino, color: "var(--yellow)" },
{ label: "Listos para retiro",value: listos, color: "var(--green)" },
{ label: "Pre-alertas pend.", value: alertasPend, color: "var(--accent)" },
{ label: "Recibidos hoy", value: recibidos, color: "var(--primary)", href: "/bodega/paquetes" },
{ label: "En verificación", value: enVerif, color: "var(--yellow)", href: "/bodega/verificacion" },
{ label: "Verificados", value: verificados, color: "var(--green)", href: "/bodega/declaraciones" },
{ label: "En tránsito a EC", value: enTransito, color: "var(--accent)", href: "/bodega/despacho" },
{ label: "Incidencias abiertas", value: incidencias, color: "var(--red)", href: "/bodega/paquetes" },
{ label: "Pre-alertas pend.", value: alertasPend, color: "#8B5CF6", href: "/bodega/paquetes" },
{ label: "Total en sistema", value: packages.length, color: "var(--gray-600)", href: "/bodega/paquetes" },
{ label: "Listos despacho", value: packages.filter(p => p.status === "DECLARACION_ADUANERA").length, color: "#0057FF", href: "/bodega/despacho" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
<Link key={s.label} href={s.href} style={{ textDecoration: "none" }}>
<div className="stat-card" style={{ cursor: "pointer", transition: "transform .15s" }}
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
>
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
</Link>
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Paquetes recientes */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Paquetes recientes</span>
<Link href="/bodega/paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Paquetes recientes</span>
<Link href="/bodega/paquetes" className="btn btn-ghost" style={{ fontSize: ".8rem", padding: ".25rem .6rem" }}>Ver todos </Link>
</div>
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<thead><tr><th>Tracking</th><th>Cliente</th><th>Estado</th></tr></thead>
<thead>
<tr>
<th>Tracking</th>
<th>Descripción</th>
<th>Estado</th>
</tr>
</thead>
<tbody>
{packages.slice(0,8).map(p => (
{packages.slice(0, 8).map(p => (
<tr key={p.id}>
<td className="font-semibold text-primary">{p.trackingId}</td>
<td className="text-sm">{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
<td><span className="badge badge-blue">{p.status.replace(/_/g," ")}</span></td>
<td style={{ fontWeight: 600, color: "var(--primary)", fontSize: ".85rem" }}>{p.trackingId}</td>
<td style={{ fontSize: ".82rem", color: "var(--gray-600)", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.description ?? ""}
</td>
<td>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`} style={{ fontSize: ".7rem" }}>
{p.status?.replace(/_/g, " ")}
</span>
</td>
</tr>
))}
{packages.length === 0 && (
<tr><td colSpan={3} style={{ textAlign: "center", color: "var(--gray-400)", padding: "2rem" }}>Sin paquetes registrados</td></tr>
)}
</tbody>
</table>
</div>
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Pre-alertas pendientes</span></div>
{alertasPend === 0 ? <p style={{ padding: "1.5rem", color: "var(--gray-500)" }}>Sin pre-alertas pendientes.</p> : (
preAlerts.filter(a => a.status === "PENDIENTE").map(a => (
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600 }}>{a.store} {a.orderNumber}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{a.description ?? "Sin descripción"}</div>
</div>
))
)}
{/* Pre-alertas + acciones rápidas */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
<div className="card">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600 }}>Pre-alertas pendientes</span>
<span style={{ fontSize: ".8rem", color: alertasPend > 0 ? "var(--yellow)" : "var(--gray-400)", fontWeight: 600 }}>
{alertasPend} pendiente{alertasPend !== 1 ? "s" : ""}
</span>
</div>
{alertasPend === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", fontSize: ".875rem" }}>Sin pre-alertas pendientes.</p>
) : (
preAlerts.filter(a => a.status === "PENDIENTE").slice(0, 5).map(a => (
<div key={a.id} style={{ padding: ".875rem 1.25rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{a.store}</div>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginTop: ".15rem" }}>
{a.description ?? "Sin descripción"} · ${a.declaredValue}
</div>
</div>
))
)}
</div>
{/* Acciones rápidas */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Acciones rápidas</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
{[
{ href: "/bodega/verificacion", icon: "⚖️", label: "Verificar paquetes (peso + fotos)" },
{ href: "/bodega/declaraciones", icon: "🛃", label: "Generar declaraciones DSI" },
{ href: "/bodega/despacho", icon: "✈️", label: "Despachar hacia Ecuador" },
{ href: "/bodega/paquetes", icon: "🔍", label: "Buscar / gestionar paquetes" },
].map(a => (
<Link key={a.href} href={a.href}
style={{
display: "flex", alignItems: "center", gap: ".75rem",
padding: ".625rem .75rem", borderRadius: 8, textDecoration: "none",
color: "var(--gray-700)", background: "var(--gray-50)",
fontSize: ".875rem", fontWeight: 500, transition: "background .15s",
}}
onMouseEnter={e => { e.currentTarget.style.background = "var(--gray-100)"; }}
onMouseLeave={e => { e.currentTarget.style.background = "var(--gray-50)"; }}
>
<span style={{ fontSize: "1.1rem" }}>{a.icon}</span>
{a.label}
</Link>
))}
</div>
</div>
</div>
</div>
</div>
+2 -1
View File
@@ -29,7 +29,8 @@ export default function LoginPage() {
// Redirigir según rol
const role = data.user.role;
if (role === "ADMIN_EMPRESA" || role === "SUPER_ADMIN") router.push("/admin");
else if (role === "OPERADOR_BODEGA" || role === "AGENTE_ADUANERO") router.push("/bodega");
else if (role === "AGENTE_ADUANERO") router.push("/aduanero");
else if (role === "OPERADOR_BODEGA") router.push("/bodega");
else router.push("/portal");
} catch (err: any) {
setError(err.message ?? "Error al iniciar sesión");
+7
View File
@@ -428,6 +428,13 @@ function Footer() {
<Link href="/casillero">Casillero</Link>
<Link href="/carga-pesada">Carga Pesada</Link>
</div>
{/* LOPDP §21 — aviso de protección de datos */}
<div style={{ borderTop: "1px solid rgba(255,255,255,.07)", marginTop: 24, paddingTop: 16, fontSize: ".72rem", color: "rgba(255,255,255,.3)", lineHeight: 1.7, textAlign: "center" }}>
🛡 Los datos personales de clientes ecuatorianos están protegidos por la{" "}
<strong style={{ color: "rgba(255,255,255,.5)" }}>Ley Orgánica de Protección de Datos Personales (LOPDP) de Ecuador</strong>.
Al procesarse en EE.UU., aplica también la normativa del estado de New Jersey.
Moraworld Imports S.A.S. (Ecuador) · Mora Global Import LLC (NJ, EE.UU.) · Documento v1.1 2026
</div>
</footer>
);
}
@@ -0,0 +1,238 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
// §21 — Portal cliente: solicitar consolidación de paquetes
// El cliente elige paquetes en estado VERIFICADO o RECIBIDO_BODEGA
// y los agrupa en un solo envío para optimizar costos
const CONSOLIDABLE = ["VERIFICADO", "RECIBIDO_BODEGA"];
const STATUS_BADGE: Record<string, string> = {
ABIERTA: "badge-blue",
CERRADA: "badge-yellow",
DESPACHADA: "badge-orange",
ENTREGADA: "badge-green",
CANCELADA: "badge-gray",
};
export default function ConsolidacionPage() {
const [packages, setPackages] = useState<any[]>([]);
const [consolidations, setConsolidations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [notes, setNotes] = useState("");
const [adding, setAdding] = useState<string | null>(null); // consolidationId en progreso
const [toast, setToast] = useState<string | null>(null);
const [expanded, setExpanded] = useState<string | null>(null);
const showToast = (msg: string) => { setToast(msg); setTimeout(() => setToast(null), 3500); };
const load = useCallback(async () => {
setLoading(true);
try {
const [pkgs, cons] = await Promise.all([api.packages.list(), api.consolidations.list()]);
setPackages(pkgs);
setConsolidations(cons);
} catch {
// silencioso
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const consolidablePackages = packages.filter(p =>
CONSOLIDABLE.includes(p.status) && !p.consolidation
);
const handleCreate = async () => {
setCreating(true);
try {
await api.consolidations.create(notes || undefined);
setNotes("");
await load();
showToast("Consolidación creada. Ahora agrega paquetes.");
} catch (e: any) {
showToast(e?.message ?? "Error al crear consolidación.");
} finally {
setCreating(false);
}
};
const handleAdd = async (consolidationId: string, packageId: string) => {
setAdding(packageId);
try {
await api.consolidations.addPackage(consolidationId, packageId);
await load();
showToast("Paquete agregado.");
} catch (e: any) {
showToast(e?.message ?? "Error al agregar paquete.");
} finally {
setAdding(null);
}
};
const handleRemove = async (consolidationId: string, packageId: string) => {
try {
await api.consolidations.removePackage(consolidationId, packageId);
await load();
showToast("Paquete removido.");
} catch (e: any) {
showToast(e?.message ?? "Error al remover paquete.");
}
};
if (loading) return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</div>
);
const openConsolidations = consolidations.filter(c => c.status === "ABIERTA");
return (
<div>
{toast && (
<div style={{
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
background: "var(--primary)", color: "#fff", borderRadius: 8,
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".875rem",
boxShadow: "0 4px 16px rgba(0,0,0,.2)",
}}>{toast}</div>
)}
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Consolidación de paquetes</h1>
<p className="dash-page-subtitle">
Agrupa varios paquetes en un solo envío para optimizar costos de flete y reducir declaraciones aduaneras. §21
</p>
</div>
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
{/* Columna izquierda — crear + paquetes disponibles */}
<div style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Nueva consolidación */}
<div className="card">
<div className="card-header"><span style={{ fontWeight: 600 }}>Nueva consolidación</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label style={{ display: "block", fontWeight: 600, fontSize: ".85rem", marginBottom: ".35rem" }}>
Notas (opcional)
</label>
<input
className="form-input"
value={notes}
onChange={e => setNotes(e.target.value)}
placeholder="Ej: Pedidos de Amazon de diciembre..."
/>
</div>
<button
className="btn btn-primary"
onClick={handleCreate}
disabled={creating || consolidablePackages.length === 0}
>
{creating ? "Creando..." : "Crear consolidación"}
</button>
{consolidablePackages.length === 0 && (
<p style={{ fontSize: ".8rem", color: "var(--gray-400)", textAlign: "center" }}>
No tienes paquetes en estado VERIFICADO disponibles para consolidar.
</p>
)}
</div>
</div>
{/* Paquetes disponibles */}
{consolidablePackages.length > 0 && (
<div className="card">
<div className="card-header">
<span style={{ fontWeight: 600 }}>Paquetes disponibles para consolidar</span>
<span className="badge badge-green" style={{ marginLeft: ".5rem" }}>{consolidablePackages.length}</span>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{consolidablePackages.map(p => (
<div key={p.id} style={{ padding: ".875rem 1.25rem", borderBottom: "1px solid var(--gray-100)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<div style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".875rem" }}>{p.trackingId}</div>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)" }}>
{p.description} · ${p.declaredValue} · {p.actualWeight ?? p.declaredWeight ?? "?"}lb
</div>
</div>
{openConsolidations.length > 0 && (
<button
className="btn btn-ghost"
style={{ fontSize: ".75rem", padding: ".25rem .6rem" }}
disabled={adding === p.id}
onClick={() => handleAdd(openConsolidations[0].id, p.id)}
>
{adding === p.id ? "..." : "+ Agregar"}
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
{/* Columna derecha — mis consolidaciones */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<h2 style={{ fontSize: "1rem", fontWeight: 700, margin: 0 }}>Mis consolidaciones</h2>
{consolidations.length === 0 ? (
<div className="card" style={{ padding: "2.5rem", textAlign: "center" }}>
<div style={{ fontSize: "2.5rem", marginBottom: ".5rem" }}>📦</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>No tienes consolidaciones aún.</p>
</div>
) : (
consolidations.map(c => (
<div key={c.id} className="card">
<div
className="card-header"
style={{ cursor: "pointer", display: "flex", justifyContent: "space-between", alignItems: "center" }}
onClick={() => setExpanded(expanded === c.id ? null : c.id)}
>
<div>
<span style={{ fontWeight: 700, color: "var(--primary)", fontSize: ".9rem" }}>{c.code}</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "badge-gray"}`} style={{ marginLeft: ".75rem", fontSize: ".7rem" }}>{c.status}</span>
</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>
{c.packages?.length ?? 0} paq · {c.totalWeightLb ?? 0}lb · ${c.totalValue ?? 0}
</div>
</div>
{expanded === c.id && (
<div className="card-body" style={{ borderTop: "1px solid var(--gray-100)" }}>
{c.notes && <p style={{ fontSize: ".82rem", color: "var(--gray-500)", marginBottom: ".75rem" }}>{c.notes}</p>}
{c.packages?.length === 0 ? (
<p style={{ fontSize: ".85rem", color: "var(--gray-400)" }}>Sin paquetes aún.</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: ".35rem" }}>
{c.packages.map((cp: any) => (
<div key={cp.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: ".4rem .5rem", background: "var(--gray-50)", borderRadius: 6 }}>
<div>
<span style={{ fontWeight: 700, fontSize: ".82rem", color: "var(--primary)" }}>{cp.package?.trackingId}</span>
<span style={{ fontSize: ".75rem", color: "var(--gray-500)", marginLeft: ".5rem" }}>{cp.package?.description}</span>
</div>
{c.status === "ABIERTA" && (
<button
className="btn btn-ghost"
style={{ fontSize: ".72rem", padding: ".2rem .5rem", color: "var(--red)" }}
onClick={() => handleRemove(c.id, cp.package?.id)}
>
</button>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
))
)}
</div>
</div>
</div>
);
}
+7 -6
View File
@@ -6,12 +6,13 @@ import { getUser, clearAuth, getRefresh } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/portal", icon: "◈", label: "Dashboard" },
{ href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" },
{ href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" },
{ href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" },
{ href: "/portal/calculadora", icon: "🧮", label: "Calculadora" },
{ href: "/portal/perfil", icon: "👤", label: "Mi Perfil" },
{ href: "/portal", icon: "◈", label: "Dashboard" },
{ href: "/portal/mi-casillero", icon: "📦", label: "Mi Casillero" },
{ href: "/portal/mis-paquetes", icon: "🚚", label: "Mis Paquetes" },
{ href: "/portal/pre-alerta", icon: "🔔", label: "Pre-Alerta" },
{ href: "/portal/consolidacion", icon: "🗃️", label: "Consolidar" },
{ href: "/portal/calculadora", icon: "🧮", label: "Calculadora" },
{ href: "/portal/perfil", icon: "👤", label: "Mi Perfil" },
];
export default function PortalLayout({ children }: { children: React.ReactNode }) {
+16
View File
@@ -92,6 +92,22 @@ export default function RegistroPage() {
<Link href="/privacidad" style={{ color: "var(--primary)" }}>Política de Privacidad</Link>.
</p>
{/* LOPDP §21 */}
<div style={{
marginTop: ".875rem",
padding: ".625rem .875rem",
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.08)",
borderRadius: 8,
fontSize: ".72rem",
color: "rgba(255,255,255,.4)",
lineHeight: 1.6,
textAlign: "center",
}}>
🛡 Tus datos están protegidos por la <strong style={{ color: "rgba(255,255,255,.6)" }}>Ley Orgánica de Protección de Datos Personales (LOPDP)</strong> de Ecuador
y la normativa del estado de New Jersey (EE.UU.). Moraworld Imports S.A.S. · Mora Global Import LLC.
</div>
<div className="text-center mt-4">
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
¿Ya tienes cuenta?{" "}
+13
View File
@@ -169,4 +169,17 @@ export const api = {
confirm: (paymentId: string) =>
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
},
consolidations: {
list: () => request<any[]>("/consolidations"),
get: (id: string) => request<any>(`/consolidations/${id}`),
create: (notes?: string) => request<any>("/consolidations", { method: "POST", body: JSON.stringify({ notes }) }),
addPackage: (id: string, packageId: string) =>
request<any>(`/consolidations/${id}/packages`, { method: "POST", body: JSON.stringify({ packageId }) }),
removePackage: (id: string, packageId: string) =>
request<any>(`/consolidations/${id}/packages/${packageId}`, { method: "DELETE" }),
close: (id: string, courierTracking?: string) =>
request<any>(`/consolidations/${id}/close`, { method: "POST", body: JSON.stringify({ courierTracking }) }),
dispatch: (id: string, courierTracking: string) =>
request<any>(`/consolidations/${id}/dispatch`, { method: "POST", body: JSON.stringify({ courierTracking }) }),
},
};