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:
@@ -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>
|
||||
|
||||
@@ -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 "Mis Paquetes".
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user