feat: C-8/C-1/C-2/C-3/C-4/C-6/M-1/M-5/M-6 — WebSocket gateway, SENAE real, SP-API, Twilio SMS, WhatsApp Business, soporte portal, HMAC audit, reportes CSV

This commit is contained in:
Lizandro Guarnizo
2026-06-01 21:28:42 -05:00
parent a5842278fb
commit 84c1fdec54
27 changed files with 1743 additions and 122 deletions
+136 -24
View File
@@ -1,8 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useMemo } from "react";
import { api } from "@/lib/api";
// §08 — estados oficiales del ciclo de vida
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
@@ -17,24 +16,73 @@ const STATUS_COLORS: Record<string, string> = {
INCIDENCIA: "#EF4444",
};
/** Convert array of objects to CSV string */
function toCSV(rows: any[], columns: { key: string; label: string }[]): string {
const header = columns.map(c => `"${c.label}"`).join(",");
const body = rows.map(row =>
columns.map(c => {
const val = row[c.key] ?? "";
const str = String(val).replace(/"/g, '""');
return `"${str}"`;
}).join(",")
);
return [header, ...body].join("\r\n");
}
/** Trigger browser download of a CSV string */
function downloadCSV(csv: string, filename: string) {
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" }); // BOM for Excel
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Default date range: last 30 days
function defaultFrom() {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().split("T")[0];
}
function defaultTo() {
return new Date().toISOString().split("T")[0];
}
export default function ReportesPage() {
const [users, setUsers] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [from, setFrom] = useState(defaultFrom());
const [to, setTo] = useState(defaultTo());
useEffect(() => {
const fetchData = () => {
setLoading(true);
Promise.all([api.users.list(), api.packages.list()])
.then(([u, p]) => { setUsers(u); setPackages(p); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
};
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
useEffect(() => { fetchData(); }, []);
// Filter packages by date range
const filteredPackages = useMemo(() => {
const fromDate = from ? new Date(from + "T00:00:00") : null;
const toDate = to ? new Date(to + "T23:59:59") : null;
return packages.filter(p => {
const d = new Date(p.createdAt);
if (fromDate && d < fromDate) return false;
if (toDate && d > toDate) return false;
return true;
});
}, [packages, from, to]);
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
filteredPackages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const totalDeclared = packages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0);
const totalDeclared = filteredPackages.reduce((a, p) => a + parseFloat(p.declaredValue ?? "0"), 0);
const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0);
const delivered = byStatus["ENTREGADO"] ?? 0;
const incidents = byStatus["INCIDENCIA"] ?? 0;
@@ -42,24 +90,83 @@ export default function ReportesPage() {
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
// ── CSV Export handlers ────────────────────────────────────────────────
const exportPackagesCSV = () => {
const cols = [
{ key: "trackingId", label: "Tracking ID" },
{ key: "description", label: "Descripción" },
{ key: "store", label: "Tienda" },
{ key: "status", label: "Estado" },
{ key: "declaredValue", label: "Valor Declarado (USD)" },
{ key: "actualWeight", label: "Peso Real (lb)" },
{ key: "senaeCategory", label: "Categoría SENAE" },
{ key: "createdAt", label: "Fecha Registro" },
];
const rows = filteredPackages.map(p => ({
...p,
createdAt: new Date(p.createdAt).toLocaleDateString("es-EC"),
}));
downloadCSV(toCSV(rows, cols), `paquetes_${from}_${to}.csv`);
};
const exportUsersCSV = () => {
const cols = [
{ key: "firstName", label: "Nombre" },
{ key: "lastName", label: "Apellido" },
{ key: "email", label: "Email" },
{ key: "phone", label: "Teléfono" },
{ key: "role", label: "Rol" },
{ key: "isActive", label: "Activo" },
{ key: "createdAt", label: "Fecha Registro" },
];
const rows = users.map(u => ({
...u,
isActive: u.isActive ? "Sí" : "No",
createdAt: new Date(u.createdAt).toLocaleDateString("es-EC"),
}));
downloadCSV(toCSV(rows, cols), `usuarios_${new Date().toISOString().split("T")[0]}.csv`);
};
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">Reportes</h1>
<p className="dash-page-subtitle">Resumen operativo del sistema ingresos, volumen y estado de envíos.</p>
<div style={{ display:"flex", justifyContent:"space-between", alignItems:"flex-start", marginBottom:"1.5rem", flexWrap:"wrap", gap:"1rem" }}>
<div>
<h1 className="dash-page-title">Reportes</h1>
<p className="dash-page-subtitle">Resumen operativo ingresos, volumen y estado de envíos.</p>
</div>
{/* Date range + export */}
<div style={{ display:"flex", alignItems:"center", gap:".75rem", flexWrap:"wrap" }}>
<div style={{ display:"flex", alignItems:"center", gap:".5rem" }}>
<label style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>Desde</label>
<input type="date" className="form-input" style={{ maxWidth:150 }} value={from} onChange={e => setFrom(e.target.value)} />
</div>
<div style={{ display:"flex", alignItems:"center", gap:".5rem" }}>
<label style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>Hasta</label>
<input type="date" className="form-input" style={{ maxWidth:150 }} value={to} onChange={e => setTo(e.target.value)} />
</div>
<button className="btn btn-secondary btn-sm" onClick={exportPackagesCSV} title="Exportar paquetes a CSV">
Paquetes CSV
</button>
<button className="btn btn-secondary btn-sm" onClick={exportUsersCSV} title="Exportar usuarios a CSV">
Usuarios CSV
</button>
</div>
</div>
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Usuarios", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" },
{ label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" },
{ label: "Paquetes en rango", value: filteredPackages.length, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC", { minimumFractionDigits:2, maximumFractionDigits:2 })}`, color: "var(--accent)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Usuarios totales", value: users.length, color: "var(--primary)" },
{ label: "Clientes", value: byRole["CLIENTE"] ?? 0, color: "#8B5CF6" },
{ label: "Pendiente aduana", value: byStatus["DECLARACION_ADUANERA"] ?? 0, color: "#0057FF" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color, fontSize: "1.75rem" }}>{s.value}</div>
@@ -71,11 +178,16 @@ export default function ReportesPage() {
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Paquetes por estado */}
<div className="card">
<div className="card-header"><span className="font-semibold">Paquetes por estado (§08)</span></div>
<div className="card-header">
<span className="font-semibold">Paquetes por estado (§08)</span>
<span style={{ fontSize:".8rem", color:"var(--gray-400)" }}>
{from} {to}
</span>
</div>
<div className="card-body">
{Object.keys(STATUS_COLORS).map(status => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
const pct = filteredPackages.length ? Math.round((count / filteredPackages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".75rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
@@ -91,7 +203,7 @@ export default function ReportesPage() {
</div>
);
})}
{packages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
{filteredPackages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos en el rango seleccionado.</p>}
</div>
</div>
@@ -115,10 +227,10 @@ export default function ReportesPage() {
<div className="card-header"><span className="font-semibold">Rendimiento</span></div>
<div className="card-body" style={{ display:"flex", flexDirection:"column", gap:".75rem" }}>
{[
["Tasa de entrega", packages.length ? `${Math.round((delivered/packages.length)*100)}%` : "—"],
["Tasa de incidencias", packages.length ? `${Math.round((incidents/packages.length)*100)}%` : "—"],
["Tasa de entrega", filteredPackages.length ? `${Math.round((delivered/filteredPackages.length)*100)}%` : "—"],
["Tasa de incidencias", filteredPackages.length ? `${Math.round((incidents/filteredPackages.length)*100)}%` : "—"],
["Pendiente declaración", byStatus["VERIFICADO"] ?? 0],
["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)],
["En bodega NJ", (byStatus["RECIBIDO_BODEGA"] ?? 0) + (byStatus["EN_VERIFICACION"] ?? 0) + (byStatus["VERIFICADO"] ?? 0)],
].map(([k, v]) => (
<div key={k as string} style={{ display:"flex", justifyContent:"space-between", padding:".375rem 0", borderBottom:"1px solid var(--gray-100)" }}>
<span style={{ color:"var(--gray-600)", fontSize:".875rem" }}>{k}</span>
+63 -11
View File
@@ -1,31 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth, getRefresh } from "@/lib/api";
import { getUser, clearAuth, getToken } from "@/lib/api";
import { api } from "@/lib/api";
const WS_URL = process.env.NEXT_PUBLIC_WS_URL ?? "http://localhost:3001";
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/consolidacion", icon: "🗃", label: "Consolidar" },
{ 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/registrar-compra", icon: "🛍", label: "Registrar Compra" },
{ 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 }) {
const router = useRouter();
const pathname = usePathname();
const [user, setUser] = useState<any>(null);
const [user, setUser] = useState<any>(null);
const [unread, setUnread] = useState(0);
const [toast, setToast] = useState<{ text: string; trackingId: string } | null>(null);
// ── Real-time WS notifications (C-6) ────────────────────────────────────
const connectWs = useCallback((token: string) => {
// Lazy-load socket.io-client only in browser
import("socket.io-client").then(({ io }) => {
const WS_NS = `${WS_URL}/ws`;
const socket = io(WS_NS, {
path: "/socket.io",
auth: { token },
transports: ["websocket", "polling"],
reconnectionAttempts: 5,
});
socket.on("package:status", (data: any) => {
setUnread(n => n + 1);
setToast({ text: `Paquete ${data.trackingId}${data.status.replace(/_/g, " ")}`, trackingId: data.trackingId });
// Auto-dismiss toast after 5 s
setTimeout(() => setToast(null), 5000);
});
socket.on("connect_error", (err: Error) => {
if (process.env.NODE_ENV === "development") {
console.debug("[WS] connect_error:", err.message);
}
});
return () => { socket.disconnect(); };
}).catch(() => {/* socket.io-client not available — SSR or CDN issue */});
}, []);
useEffect(() => {
const u = getUser();
if (!u) { router.replace("/login"); return; }
setUser(u);
}, [router]);
const token = getToken();
if (token) connectWs(token);
}, [router, connectWs]);
const handleLogout = async () => {
try { await api.auth.logout(localStorage.getItem("mw_refresh") ?? ""); } catch {}
@@ -37,6 +73,22 @@ export default function PortalLayout({ children }: { children: React.ReactNode }
return (
<div className="dash-layout">
{/* Toast (WS notification) */}
{toast && (
<div style={{
position: "fixed", bottom: 24, right: 24, zIndex: 9999,
background: "var(--primary)", color: "white", borderRadius: 10,
padding: "12px 20px", boxShadow: "0 4px 24px rgba(0,0,0,.25)",
maxWidth: 340, fontSize: ".875rem", fontWeight: 500,
display: "flex", alignItems: "center", gap: 12,
animation: "slideIn .25s ease",
}}>
<span>🚀</span>
<span>{toast.text}</span>
<button onClick={() => setToast(null)} style={{ background: "none", border: "none", color: "rgba(255,255,255,.7)", cursor: "pointer", fontSize: "1rem", marginLeft: "auto" }}>×</button>
</div>
)}
{/* Sidebar */}
<aside className="dash-sidebar">
<div className="dash-logo">Mora<span>world</span></div>
@@ -0,0 +1,177 @@
"use client";
import { useState } from "react";
import { api } from "@/lib/api";
const SENAE_OPTIONS = [
{ value: "REGIMEN_4X4", label: "Régimen 4×4 (hasta $400, exento aranceles)" },
{ value: "CATEGORIA_B", label: "Categoría B (electrónicos, 10%)" },
{ value: "CATEGORIA_C", label: "Categoría C (varios, 20%)" },
{ value: "CATEGORIA_D", label: "Categoría D (textiles/calzado, 10% base)" },
];
const FIELD_HINTS: Record<string, string> = {
store: "Ej: Amazon, eBay, Walmart",
vendorTracking: "Número de rastreo del vendedor/courier (UPS, FedEx, USPS…)",
productUrl: "URL del producto en la tienda (opcional, para referencia)",
declaredValue: "Valor en USD que declaras a aduana. Debe ser exacto.",
declaredWeight: "Peso aproximado en libras. La bodega pesará al recibir.",
};
export default function RegistrarCompraPage() {
const [form, setForm] = useState({
description: "",
store: "",
vendorTracking: "",
productUrl: "",
declaredValue: "",
declaredWeight: "",
senaeCategory: "REGIMEN_4X4",
});
const [loading, setSaving] = useState(false);
const [success, setSuccess] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const update = (field: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [field]: e.target.value }));
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.description.trim()) { setError("La descripción del producto es obligatoria."); return; }
setSaving(true);
setError(null);
setSuccess(null);
try {
const body: any = {
description: form.description,
senaeCategory: form.senaeCategory,
};
if (form.store.trim()) body.store = form.store;
if (form.vendorTracking.trim()) body.vendorTracking = form.vendorTracking;
if (form.productUrl.trim()) body.productUrl = form.productUrl;
if (form.declaredValue.trim()) body.declaredValue = parseFloat(form.declaredValue);
if (form.declaredWeight.trim()) body.declaredWeightLb = parseFloat(form.declaredWeight);
const pkg = await api.packages.register(body);
setSuccess(pkg);
setForm({ description: "", store: "", vendorTracking: "", productUrl: "", declaredValue: "", declaredWeight: "", senaeCategory: "REGIMEN_4X4" });
} catch (err: any) {
setError(err.message ?? "Error al registrar el paquete.");
} finally {
setSaving(false);
}
};
return (
<div style={{ maxWidth: 680, margin: "0 auto" }}>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Registrar Compra</h1>
<p className="dash-page-subtitle">
Registra una compra realizada en EE.UU. antes de que llegue a nuestra bodega.
Te asignaremos un tracking ID de Moraworld para seguimiento completo (§09).
</p>
</div>
{success && (
<div className="alert alert-success" style={{ marginBottom: "1.5rem" }}>
<strong>Compra registrada exitosamente.</strong><br />
Tracking ID: <code style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{success.trackingId}</code><br />
<span style={{ fontSize: ".85rem", color: "var(--gray-600)" }}>
Guarda este código para rastrear tu paquete desde &quot;Mis Paquetes&quot;.
</span>
</div>
)}
{error && (
<div className="alert alert-error" style={{ marginBottom: "1rem" }}>{error}</div>
)}
<form onSubmit={handleSubmit} className="card">
<div className="card-header"><span className="font-semibold">Información del producto</span></div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* Descripción */}
<div className="form-group">
<label className="form-label">Descripción del producto *</label>
<input
className="form-input"
placeholder="Ej: Auriculares Bluetooth Sony WH-1000XM5 negro"
value={form.description}
onChange={update("description")}
required
/>
<small className="form-hint">Describe brevemente qué compraste. Esto ayuda al agente aduanero.</small>
</div>
{/* Tienda + Tracking del vendedor */}
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="form-label">Tienda</label>
<input className="form-input" placeholder={FIELD_HINTS.store} value={form.store} onChange={update("store")} />
</div>
<div className="form-group">
<label className="form-label">Tracking del vendedor</label>
<input className="form-input" placeholder={FIELD_HINTS.vendorTracking} value={form.vendorTracking} onChange={update("vendorTracking")} />
</div>
</div>
{/* URL del producto */}
<div className="form-group">
<label className="form-label">URL del producto</label>
<input className="form-input" type="url" placeholder={FIELD_HINTS.productUrl} value={form.productUrl} onChange={update("productUrl")} />
</div>
{/* Valor + Peso */}
<div className="grid-2" style={{ gap: "1rem" }}>
<div className="form-group">
<label className="form-label">Valor declarado (USD)</label>
<input className="form-input" type="number" min="0" step="0.01" placeholder="0.00" value={form.declaredValue} onChange={update("declaredValue")} />
<small className="form-hint">{FIELD_HINTS.declaredValue}</small>
</div>
<div className="form-group">
<label className="form-label">Peso aprox. (lb)</label>
<input className="form-input" type="number" min="0" step="0.1" placeholder="0.0" value={form.declaredWeight} onChange={update("declaredWeight")} />
<small className="form-hint">{FIELD_HINTS.declaredWeight}</small>
</div>
</div>
{/* Categoría SENAE */}
<div className="form-group">
<label className="form-label">Categoría SENAE</label>
<select className="form-input" value={form.senaeCategory} onChange={update("senaeCategory")}>
{SENAE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<small className="form-hint">
El agente aduanero puede cambiar la categoría al recibir el paquete. Para la mayoría de productos
personales el <strong>Régimen 4×4</strong> aplica si el valor es menor a $400.
</small>
</div>
<div style={{ paddingTop: ".5rem" }}>
<button type="submit" className="btn btn-primary" disabled={loading} style={{ width: "100%" }}>
{loading ? <span><span className="spinner" style={{ width:16, height:16, marginRight:8 }} />Registrando...</span> : "Registrar Compra"}
</button>
</div>
</div>
</form>
{/* Info box */}
<div className="card" style={{ marginTop: "1.5rem", background: "var(--primary-50, #eff6ff)", border: "1px solid var(--primary-100, #dbeafe)" }}>
<div className="card-body" style={{ fontSize: ".85rem", color: "var(--gray-700)" }}>
<p style={{ fontWeight: 600, marginBottom: ".5rem" }}>¿Cómo funciona el flujo?</p>
<ol style={{ margin: 0, paddingLeft: "1.25rem", lineHeight: 1.8 }}>
<li>Registras tu compra aquí obtienes un Tracking ID Moraworld.</li>
<li>El producto llega a nuestra bodega en New Jersey.</li>
<li>Lo verificamos, pesamos y fotografiamos.</li>
<li>Generamos la Declaración Simplificada (DSI) ante la SENAE.</li>
<li>El paquete viaja a Ecuador te notificamos en cada paso.</li>
<li>Pagas el cobro final y coordinamos la entrega.</li>
</ol>
</div>
</div>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: "Super Admin",
ADMIN_EMPRESA: "Admin Empresa",
OPERADOR_BODEGA: "Operador Bodega",
AGENTE_ADUANERO: "Agente Aduanero",
CLIENTE: "Cliente",
SOPORTE: "Soporte",
};
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: "#EF4444",
ADMIN_EMPRESA: "#F97316",
OPERADOR_BODEGA: "#3B82F6",
AGENTE_ADUANERO: "#8B5CF6",
CLIENTE: "#10B981",
SOPORTE: "#F59E0B",
};
export default function SoporteClientesPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [role, setRole] = useState("");
useEffect(() => {
api.users.list(search || undefined)
.then(setUsers)
.catch(() => {})
.finally(() => setLoading(false));
}, [search]);
const filtered = users.filter(u => {
if (role && u.role !== role) return false;
return true;
});
const byRole: Record<string, number> = {};
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Clientes y Usuarios Solo lectura</h1>
<p className="dash-page-subtitle">Consulta el directorio de usuarios del sistema.</p>
</div>
{/* Role stats */}
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{Object.entries(ROLE_LABELS).map(([r, label]) => (
<div key={r} className="stat-card" style={{ cursor:"pointer", border: role === r ? "2px solid var(--primary)" : undefined }}
onClick={() => setRole(role === r ? "" : r)}>
<div className="stat-value" style={{ color: ROLE_COLORS[r] ?? "var(--primary)", fontSize: "1.5rem" }}>{byRole[r] ?? 0}</div>
<div className="stat-label" style={{ fontSize:".75rem" }}>{label}</div>
</div>
))}
</div>
{/* Filters */}
<div style={{ display:"flex", gap:"1rem", marginBottom:"1.25rem", flexWrap:"wrap" }}>
<input
className="form-input"
style={{ maxWidth: 280 }}
placeholder="Buscar por nombre o email..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<select className="form-input" style={{ maxWidth: 200 }} value={role} onChange={e => setRole(e.target.value)}>
<option value="">Todos los roles</option>
{Object.entries(ROLE_LABELS).map(([r, label]) => (
<option key={r} value={r}>{label}</option>
))}
</select>
<span style={{ fontSize:".875rem", color:"var(--gray-500)", alignSelf:"center" }}>
{filtered.length} usuario{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{loading ? (
<div style={{ display:"flex", justifyContent:"center", padding:"3rem" }}><div className="spinner" /></div>
) : (
<div className="card">
<div style={{ overflowX:"auto" }}>
<table className="table">
<thead>
<tr>
<th>Nombre</th>
<th>Email</th>
<th>Teléfono</th>
<th>Rol</th>
<th>Suite</th>
<th>MFA</th>
<th>Estado</th>
<th>Registro</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={8} style={{ textAlign:"center", color:"var(--gray-400)", padding:"2rem" }}>Sin resultados</td></tr>
)}
{filtered.map(u => (
<tr key={u.id}>
<td style={{ fontWeight:500 }}>{u.firstName} {u.lastName}</td>
<td style={{ fontSize:".85rem", color:"var(--gray-600)" }}>{u.email}</td>
<td style={{ fontSize:".85rem" }}>{u.phone ?? "—"}</td>
<td>
<span style={{
display:"inline-block",
background: (ROLE_COLORS[u.role] ?? "#6B7280") + "20",
color: ROLE_COLORS[u.role] ?? "#6B7280",
borderRadius:999, padding:"2px 10px", fontSize:".75rem", fontWeight:600,
}}>
{ROLE_LABELS[u.role] ?? u.role}
</span>
</td>
<td style={{ fontSize:".85rem" }}>
{u.suite ? (
<code style={{ fontSize:".8rem" }}>{u.suite.code}</code>
) : <span style={{ color:"var(--gray-400)" }}></span>}
</td>
<td style={{ textAlign:"center" }}>
{u.mfaEnabled ? (
<span title="MFA activo" style={{ color:"var(--green)", fontWeight:700 }}></span>
) : (
<span style={{ color:"var(--gray-300)" }}></span>
)}
</td>
<td>
<span style={{
display:"inline-block",
background: u.isActive ? "var(--green-50, #f0fdf4)" : "var(--red-50, #fef2f2)",
color: u.isActive ? "var(--green)" : "var(--red)",
borderRadius:999, padding:"2px 10px", fontSize:".75rem", fontWeight:600,
}}>
{u.isActive ? "Activo" : "Inactivo"}
</span>
</td>
<td style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>
{new Date(u.createdAt).toLocaleDateString("es-EC")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { getUser, clearAuth } from "@/lib/api";
import { api } from "@/lib/api";
const NAV = [
{ href: "/soporte", icon: "◈", label: "Dashboard" },
{ href: "/soporte/paquetes", icon: "📦", label: "Paquetes" },
{ href: "/soporte/clientes", icon: "👥", label: "Clientes" },
];
export default function SoporteLayout({ 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 (!["SOPORTE", "ADMIN_EMPRESA", "SUPER_ADMIN"].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" style={{ background: "var(--gray-900)" }}>
<div className="dash-logo" style={{ color: "var(--yellow)" }}>
Soporte<span style={{ color: "white" }}>Portal</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 !== "/soporte" && 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: "var(--yellow)" }}>Soporte</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 de Soporte</span>
<span style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>
{user.email} <strong>Solo lectura</strong>
</span>
</header>
<main className="dash-content">{children}</main>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
RECIBIDO_BODEGA: "#3B82F6",
EN_VERIFICACION: "#8B5CF6",
VERIFICADO: "#10B981",
DECLARACION_ADUANERA: "#0057FF",
EN_TRANSITO_ECUADOR: "#F97316",
EN_ADUANA_ECUADOR: "#EF4444",
LISTO_ENTREGA: "#84CC16",
ENTREGADO: "#10B981",
INCIDENCIA: "#EF4444",
};
export default function SoportePage() {
const [packages, setPackages] = useState<any[]>([]);
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([api.packages.list(), api.users.list()])
.then(([p, u]) => { setPackages(p); setUsers(u); })
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <div style={{ display:"flex", justifyContent:"center", padding:"4rem" }}><div className="spinner" /></div>;
const byStatus: Record<string, number> = {};
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
const incidents = byStatus["INCIDENCIA"] ?? 0;
const inTransit = (byStatus["EN_TRANSITO_BODEGA"] ?? 0) + (byStatus["EN_TRANSITO_ECUADOR"] ?? 0);
const delivered = byStatus["ENTREGADO"] ?? 0;
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Dashboard de Soporte</h1>
<p className="dash-page-subtitle">Vista de solo lectura. Para modificar datos usa el panel de Admin.</p>
</div>
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
{[
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
{ label: "En tránsito", value: inTransit, color: "var(--yellow)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "Incidencias", value: incidents, color: "var(--red)" },
{ label: "Total clientes", value: users.filter(u => u.role === "CLIENTE").length, color: "#8B5CF6" },
{ label: "Total usuarios", value: users.length, color: "var(--primary)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color, fontSize: "1.75rem" }}>{s.value}</div>
<div className="stat-label">{s.label}</div>
</div>
))}
</div>
<div className="card">
<div className="card-header"><span className="font-semibold">Distribución por estado (§08)</span></div>
<div className="card-body">
{Object.keys(STATUS_COLORS).map(status => {
const count = byStatus[status] ?? 0;
const pct = packages.length ? Math.round((count / packages.length) * 100) : 0;
return (
<div key={status} style={{ marginBottom: ".75rem" }}>
<div style={{ display:"flex", justifyContent:"space-between", marginBottom:".2rem" }}>
<span style={{ fontSize:".8rem", display:"flex", alignItems:"center", gap:".4rem" }}>
<span style={{ width:8, height:8, borderRadius:"50%", background: STATUS_COLORS[status], display:"inline-block" }} />
{status.replace(/_/g," ")}
</span>
<span style={{ fontWeight:700, fontSize:".85rem" }}>{count}</span>
</div>
<div style={{ height:4, background:"var(--gray-100)", borderRadius:999 }}>
<div style={{ height:4, borderRadius:999, background: STATUS_COLORS[status], width:`${pct}%`, transition:"width .4s" }} />
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
const STATUS_LABELS: Record<string, string> = {
REGISTRADO: "Registrado",
EN_TRANSITO_BODEGA: "En tránsito → NJ",
RECIBIDO_BODEGA: "En bodega NJ",
EN_VERIFICACION: "En verificación",
VERIFICADO: "Verificado",
DECLARACION_ADUANERA: "Declaración SENAE",
EN_TRANSITO_ECUADOR: "En tránsito → EC",
EN_ADUANA_ECUADOR: "En aduana EC",
LISTO_ENTREGA: "Listo para entrega",
ENTREGADO: "Entregado",
INCIDENCIA: "Incidencia",
};
const STATUS_COLORS: Record<string, string> = {
REGISTRADO: "#6B7280",
EN_TRANSITO_BODEGA: "#F59E0B",
RECIBIDO_BODEGA: "#3B82F6",
EN_VERIFICACION: "#8B5CF6",
VERIFICADO: "#10B981",
DECLARACION_ADUANERA: "#0057FF",
EN_TRANSITO_ECUADOR: "#F97316",
EN_ADUANA_ECUADOR: "#EF4444",
LISTO_ENTREGA: "#84CC16",
ENTREGADO: "#10B981",
INCIDENCIA: "#EF4444",
};
export default function SoportePaquetesPage() {
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [filter, setFilter] = useState("");
useEffect(() => {
const params: Record<string, string> = {};
if (filter) params.status = filter;
if (search) params.search = search;
api.packages.list(Object.keys(params).length ? params : undefined)
.then(setPackages)
.catch(() => {})
.finally(() => setLoading(false));
}, [filter, search]);
const filtered = packages.filter(p => {
if (!search) return true;
const q = search.toLowerCase();
return (
(p.trackingId ?? "").toLowerCase().includes(q) ||
(p.description ?? "").toLowerCase().includes(q) ||
(p.user?.email ?? "").toLowerCase().includes(q)
);
});
return (
<div>
<div style={{ marginBottom: "1.5rem" }}>
<h1 className="dash-page-title">Paquetes Solo lectura</h1>
<p className="dash-page-subtitle">Consulta el estado de todos los paquetes del sistema.</p>
</div>
{/* Filters */}
<div style={{ display:"flex", gap:"1rem", marginBottom:"1.25rem", flexWrap:"wrap" }}>
<input
className="form-input"
style={{ maxWidth: 280 }}
placeholder="Buscar por tracking, descripción, email..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
<select className="form-input" style={{ maxWidth: 240 }} value={filter} onChange={e => setFilter(e.target.value)}>
<option value="">Todos los estados</option>
{Object.keys(STATUS_LABELS).map(s => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
<span style={{ fontSize:".875rem", color:"var(--gray-500)", alignSelf:"center" }}>
{filtered.length} paquete{filtered.length !== 1 ? "s" : ""}
</span>
</div>
{loading ? (
<div style={{ display:"flex", justifyContent:"center", padding:"3rem" }}><div className="spinner" /></div>
) : (
<div className="card">
<div style={{ overflowX: "auto" }}>
<table className="table">
<thead>
<tr>
<th>Tracking ID</th>
<th>Descripción</th>
<th>Cliente</th>
<th>Tienda</th>
<th>Peso real (lb)</th>
<th>Estado</th>
<th>Fecha</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 && (
<tr><td colSpan={7} style={{ textAlign:"center", color:"var(--gray-400)", padding:"2rem" }}>Sin resultados</td></tr>
)}
{filtered.map(pkg => (
<tr key={pkg.id}>
<td><code style={{ fontSize:".8rem", fontWeight:600 }}>{pkg.trackingId}</code></td>
<td style={{ maxWidth:220, overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>
{pkg.description ?? "—"}
</td>
<td style={{ fontSize:".8rem" }}>
{pkg.user ? `${pkg.user.firstName} ${pkg.user.lastName}` : "—"}
{pkg.user?.email && <div style={{ color:"var(--gray-400)", fontSize:".75rem" }}>{pkg.user.email}</div>}
</td>
<td style={{ fontSize:".85rem" }}>{pkg.store ?? "—"}</td>
<td style={{ textAlign:"center" }}>
{pkg.actualWeight ? `${Number(pkg.actualWeight).toFixed(1)} lb` : "—"}
</td>
<td>
<span style={{
display:"inline-flex", alignItems:"center", gap:5,
background: (STATUS_COLORS[pkg.status] ?? "#6B7280") + "20",
color: STATUS_COLORS[pkg.status] ?? "#6B7280",
borderRadius:999, padding:"2px 10px", fontSize:".75rem", fontWeight:600, whiteSpace:"nowrap",
}}>
{STATUS_LABELS[pkg.status] ?? pkg.status}
</span>
</td>
<td style={{ fontSize:".8rem", color:"var(--gray-500)", whiteSpace:"nowrap" }}>
{new Date(pkg.createdAt).toLocaleDateString("es-EC")}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -110,6 +110,7 @@ export const api = {
}).then(r => { if (!r.ok) throw new Error("Upload failed"); return r.json(); });
},
senaeDeclare: (id: string, body: any) => request<any>(`/packages/${id}/senae/declare`, { method: "POST", body: JSON.stringify(body) }),
register: (body: any) => request<any>("/packages/register", { method: "POST", body: JSON.stringify(body) }),
},
preAlerts: {
list: () => request<any[]>("/pre-alerts"),