feat: payments module, notification templates, WA float, route fixes
- Add PaymentsModule: POST /payments/intent, POST /:id/confirm, GET /payments, GET /payments/package/:id, GET /payments/track/:trackingId
- Add Payment model to Prisma schema (PaymentStatus enum, Payment table)
- Add NotificationTemplate model + NotificationsController (GET/PUT /notification-templates, POST /notification-templates/seed)
- Update NotificationsService: DB-backed templates with variable interpolation {{trackingId}} {{firstName}} {{status}} {{suiteCode}}
- Fix /bodega/paquetes: replace 11 wrong status strings with correct §08 enum values
- Fix /admin/reportes: replace EN_CAMINO_A_ECUADOR with correct §08 statuses, rewrite report page with proper KPIs and bar charts
- Fix /portal/mis-paquetes: correct §08 statuses, add 'Pagar envío' button for VERIFICADO/DECLARACION_ADUANERA packages
- Add WhatsApp float component (_components/whatsapp-float.tsx, 2 contacts: NJ ops + Cuenca aduana)
- Add /casillero/calculadora and /casillero/registro redirects (§20)
- Add /portal/pago payment page with cost breakdown (§09/§14/§15)
- Add /admin/notificaciones page: view/edit/toggle templates per event×channel
- Admin nav: add Notificaciones link
- api.ts: add notificationTemplates.* and payments.* client methods
- schema.prisma v0.4: PaymentStatus enum, Payment model, NotificationTemplate model
- db push applied to remote DB (46.202.93.92)
- All builds pass (API nest build + Next.js build)
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
|
||||
// §07 + §21 — Botón flotante WhatsApp con dos contactos
|
||||
// Reemplaza los números de teléfono con los reales antes de producción.
|
||||
const CONTACTS = [
|
||||
{
|
||||
label: "Operaciones NJ",
|
||||
sub: "150 N Day St · New Jersey",
|
||||
phone: "12015550100", // ← reemplazar con número real
|
||||
msg: "Hola, tengo una consulta sobre mi paquete en New Jersey.",
|
||||
},
|
||||
{
|
||||
label: "Aduana · Cuenca",
|
||||
sub: "Moraworld Imports S.A.S.",
|
||||
phone: "593987654321", // ← reemplazar con número real
|
||||
msg: "Hola, necesito información sobre trámites aduaneros en Ecuador.",
|
||||
},
|
||||
];
|
||||
|
||||
export function WhatsAppFloat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed", bottom: "1.5rem", right: "1.5rem",
|
||||
zIndex: 9999, display: "flex", flexDirection: "column",
|
||||
alignItems: "flex-end", gap: ".75rem",
|
||||
}}
|
||||
>
|
||||
{/* Opciones desplegables */}
|
||||
{open && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: ".5rem" }}>
|
||||
{CONTACTS.map(c => (
|
||||
<a
|
||||
key={c.phone}
|
||||
href={`https://wa.me/${c.phone}?text=${encodeURIComponent(c.msg)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: "flex", alignItems: "center", gap: ".75rem",
|
||||
background: "#fff", borderRadius: 12, padding: ".75rem 1rem",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,.15)",
|
||||
textDecoration: "none", color: "#111827",
|
||||
minWidth: 220, transition: "transform .15s",
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = "translateX(-4px)"; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = "translateX(0)"; }}
|
||||
>
|
||||
<span style={{ fontSize: "1.4rem", lineHeight: 1 }}>💬</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: ".88rem" }}>{c.label}</div>
|
||||
<div style={{ fontSize: ".75rem", color: "#6B7280" }}>{c.sub}</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botón principal */}
|
||||
<button
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-label="Contactar por WhatsApp"
|
||||
style={{
|
||||
width: 56, height: 56, borderRadius: "50%",
|
||||
background: "#25D366", border: "none", cursor: "pointer",
|
||||
display: "flex", alignItems: "center", justifyContent: "center",
|
||||
boxShadow: "0 4px 16px rgba(37,211,102,.45)",
|
||||
transition: "transform .2s, box-shadow .2s",
|
||||
transform: open ? "rotate(45deg)" : "rotate(0deg)",
|
||||
}}
|
||||
onMouseEnter={e => {
|
||||
e.currentTarget.style.transform = open ? "rotate(45deg) scale(1.1)" : "scale(1.1)";
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
e.currentTarget.style.transform = open ? "rotate(45deg)" : "scale(1)";
|
||||
}}
|
||||
>
|
||||
{open ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="white" strokeWidth="2.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="26" height="26" viewBox="0 0 24 24" fill="white">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347z" />
|
||||
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.948-1.42A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,13 +6,14 @@ import { getUser, clearAuth } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/admin", icon: "◈", label: "Dashboard" },
|
||||
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
|
||||
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
||||
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
||||
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
||||
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
|
||||
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
|
||||
{ href: "/admin", icon: "◈", label: "Dashboard" },
|
||||
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
|
||||
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
||||
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
|
||||
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
||||
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
||||
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
|
||||
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// §12 — Plantillas de notificación por evento × canal
|
||||
// Variables disponibles: {{trackingId}}, {{firstName}}, {{status}}, {{suiteCode}}
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
REGISTRADO: "Paquete registrado",
|
||||
EN_TRANSITO_BODEGA: "En tránsito a bodega NJ",
|
||||
RECIBIDO_BODEGA: "Recibido en bodega NJ",
|
||||
EN_VERIFICACION: "En verificación",
|
||||
VERIFICADO: "Verificado",
|
||||
DECLARACION_ADUANERA: "Declaración aduanera",
|
||||
EN_TRANSITO_ECUADOR: "En tránsito a Ecuador",
|
||||
EN_ADUANA_ECUADOR: "En aduana Ecuador",
|
||||
LISTO_ENTREGA: "Listo para entrega",
|
||||
ENTREGADO: "Entregado",
|
||||
INCIDENCIA: "Incidencia reportada",
|
||||
};
|
||||
|
||||
const CHANNEL_ICON: Record<string, string> = {
|
||||
EMAIL: "📧",
|
||||
WHATSAPP: "💬",
|
||||
PUSH: "🔔",
|
||||
};
|
||||
|
||||
const CHANNEL_COLOR: Record<string, string> = {
|
||||
EMAIL: "#3B82F6",
|
||||
WHATSAPP: "#25D366",
|
||||
PUSH: "#8B5CF6",
|
||||
};
|
||||
|
||||
const EVENTS_ORDER = Object.keys(EVENT_LABELS);
|
||||
const CHANNELS = ["EMAIL", "WHATSAPP", "PUSH"];
|
||||
|
||||
type Template = {
|
||||
id: string;
|
||||
event: string;
|
||||
channel: string;
|
||||
subject: string | null;
|
||||
body: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
export default function NotificacionesPage() {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
const [editing, setEditing] = useState<Template | null>(null);
|
||||
const [editBody, setEditBody] = useState("");
|
||||
const [editSubject, setEditSubject] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toast, setToast] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.notificationTemplates.list();
|
||||
setTemplates(data);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
const handleSeed = async () => {
|
||||
setSeeding(true);
|
||||
try {
|
||||
await api.notificationTemplates.seed();
|
||||
await load();
|
||||
showToast("Plantillas por defecto creadas correctamente.");
|
||||
} catch {
|
||||
showToast("Error al crear plantillas.");
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (tpl: Template) => {
|
||||
setEditing(tpl);
|
||||
setEditBody(tpl.body);
|
||||
setEditSubject(tpl.subject ?? "");
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editing) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.notificationTemplates.update(editing.id, {
|
||||
body: editBody,
|
||||
subject: editSubject || undefined,
|
||||
isActive: editing.isActive,
|
||||
});
|
||||
setEditing(null);
|
||||
await load();
|
||||
showToast("Plantilla guardada.");
|
||||
} catch {
|
||||
showToast("Error al guardar.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleActive = async (tpl: Template) => {
|
||||
try {
|
||||
await api.notificationTemplates.update(tpl.id, {
|
||||
body: tpl.body,
|
||||
subject: tpl.subject ?? undefined,
|
||||
isActive: !tpl.isActive,
|
||||
});
|
||||
setTemplates(prev => prev.map(t => t.id === tpl.id ? { ...t, isActive: !t.isActive } : t));
|
||||
} catch {
|
||||
showToast("Error al actualizar estado.");
|
||||
}
|
||||
};
|
||||
|
||||
// Agrupa plantillas: { event → { channel → Template } }
|
||||
const byEvent: Record<string, Record<string, Template>> = {};
|
||||
templates.forEach(t => {
|
||||
if (!byEvent[t.event]) byEvent[t.event] = {};
|
||||
byEvent[t.event][t.channel] = t;
|
||||
});
|
||||
|
||||
const isEmpty = templates.length === 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div style={{
|
||||
position: "fixed", top: "1rem", right: "1rem", zIndex: 9999,
|
||||
background: "var(--green)", color: "#fff", borderRadius: 8,
|
||||
padding: ".75rem 1.25rem", fontWeight: 600, fontSize: ".9rem",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,.15)",
|
||||
}}>{toast}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: "1.5rem" }}>
|
||||
<div>
|
||||
<h1 className="dash-page-title">Plantillas de notificación</h1>
|
||||
<p className="dash-page-subtitle">
|
||||
Configura los mensajes enviados a clientes en cada estado del ciclo de vida (§12).
|
||||
<br />
|
||||
Variables disponibles: <code>{"{{trackingId}}"}</code> <code>{"{{firstName}}"}</code> <code>{"{{status}}"}</code> <code>{"{{suiteCode}}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
{isEmpty && !loading && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSeed}
|
||||
disabled={seeding}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{seeding ? "Creando..." : "Crear plantillas por defecto"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="card" style={{ textAlign: "center", padding: "3rem" }}>
|
||||
<div style={{ fontSize: "2.5rem", marginBottom: ".75rem" }}>📋</div>
|
||||
<p style={{ color: "var(--gray-500)", marginBottom: "1rem" }}>
|
||||
No hay plantillas configuradas. Haz click en "Crear plantillas por defecto" para empezar.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{EVENTS_ORDER.map(event => {
|
||||
const row = byEvent[event] ?? {};
|
||||
const hasAny = Object.keys(row).length > 0;
|
||||
if (!hasAny) return null;
|
||||
return (
|
||||
<div key={event} className="card">
|
||||
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontWeight: 700 }}>{EVENT_LABELS[event] ?? event}</span>
|
||||
<span style={{ fontSize: ".75rem", color: "var(--gray-500)", fontFamily: "monospace" }}>{event}</span>
|
||||
</div>
|
||||
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: ".75rem" }}>
|
||||
{CHANNELS.map(channel => {
|
||||
const tpl = row[channel];
|
||||
if (!tpl) return null;
|
||||
return (
|
||||
<div
|
||||
key={channel}
|
||||
style={{
|
||||
border: `1px solid ${tpl.isActive ? "var(--gray-200)" : "var(--gray-100)"}`,
|
||||
borderRadius: 8, padding: ".875rem 1rem",
|
||||
opacity: tpl.isActive ? 1 : 0.55,
|
||||
background: tpl.isActive ? "#fff" : "var(--gray-50)",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".5rem" }}>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: ".5rem", fontWeight: 600, fontSize: ".875rem" }}>
|
||||
<span style={{ fontSize: "1rem" }}>{CHANNEL_ICON[channel]}</span>
|
||||
<span style={{ color: CHANNEL_COLOR[channel] }}>{channel}</span>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: ".5rem", alignItems: "center" }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: ".75rem", padding: ".25rem .6rem" }}
|
||||
onClick={() => openEdit(tpl)}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<label className="toggle-wrap" style={{ margin: 0 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tpl.isActive}
|
||||
onChange={() => toggleActive(tpl)}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
<span
|
||||
className="toggle-track"
|
||||
onClick={() => toggleActive(tpl)}
|
||||
style={{ background: tpl.isActive ? "var(--primary)" : "var(--gray-300)", cursor: "pointer" }}
|
||||
>
|
||||
<span className="toggle-thumb" style={{ transform: tpl.isActive ? "translateX(20px)" : "translateX(0)" }} />
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{channel === "EMAIL" && tpl.subject && (
|
||||
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginBottom: ".35rem" }}>
|
||||
<strong>Asunto:</strong> {tpl.subject}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: ".82rem", color: "var(--gray-700)", lineHeight: 1.5 }}>
|
||||
{tpl.body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de edición */}
|
||||
{editing && (
|
||||
<div className="modal-overlay" onClick={() => setEditing(null)}>
|
||||
<div className="modal-box" onClick={e => e.stopPropagation()} style={{ maxWidth: 580 }}>
|
||||
<div className="modal-header">
|
||||
<span>
|
||||
{CHANNEL_ICON[editing.channel]} Editar plantilla —{" "}
|
||||
<strong>{EVENT_LABELS[editing.event] ?? editing.event}</strong>{" "}
|
||||
/ <span style={{ color: CHANNEL_COLOR[editing.channel] }}>{editing.channel}</span>
|
||||
</span>
|
||||
<button className="btn btn-ghost" style={{ padding: ".25rem .5rem" }} onClick={() => setEditing(null)}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{editing.channel === "EMAIL" && (
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: ".85rem", fontWeight: 600, marginBottom: ".35rem" }}>
|
||||
Asunto del correo
|
||||
</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={editSubject}
|
||||
onChange={e => setEditSubject(e.target.value)}
|
||||
placeholder="Ej: Tu paquete {{trackingId}} fue entregado"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label style={{ display: "block", fontSize: ".85rem", fontWeight: 600, marginBottom: ".35rem" }}>
|
||||
Cuerpo del mensaje
|
||||
</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={6}
|
||||
value={editBody}
|
||||
onChange={e => setEditBody(e.target.value)}
|
||||
placeholder="Hola {{firstName}}, tu paquete {{trackingId}} ..."
|
||||
style={{ resize: "vertical", fontFamily: "inherit" }}
|
||||
/>
|
||||
<p style={{ fontSize: ".75rem", color: "var(--gray-500)", marginTop: ".35rem" }}>
|
||||
Variables: <code>{"{{trackingId}}"}</code> <code>{"{{firstName}}"}</code> <code>{"{{status}}"}</code> <code>{"{{suiteCode}}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-ghost" onClick={() => setEditing(null)}>Cancelar</button>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !editBody.trim()}>
|
||||
{saving ? "Guardando..." : "Guardar cambios"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,66 +2,130 @@
|
||||
import { useEffect, useState } 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",
|
||||
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 ReportesPage() {
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [packages, setPackages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.users.list(), api.packages.list()])
|
||||
.then(([u, p]) => { setUsers(u); setPackages(p); })
|
||||
.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 byStatus: Record<string, number> = {};
|
||||
packages.forEach(p => { byStatus[p.status] = (byStatus[p.status] ?? 0) + 1; });
|
||||
|
||||
const totalDeclared = packages.reduce((a, p) => a + (p.declaredValueUsd ?? 0), 0);
|
||||
const totalDeclared = packages.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;
|
||||
|
||||
const byRole: Record<string, number> = {};
|
||||
users.forEach(u => { byRole[u.role] = (byRole[u.role] ?? 0) + 1; });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="dash-page-title">Reportes</h1><p className="dash-page-subtitle">Resumen operativo del sistema.</p></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>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid-4" style={{ marginBottom: "1.5rem" }}>
|
||||
{[
|
||||
{ label: "Total paquetes", value: packages.length, color: "var(--primary)" },
|
||||
{ label: "Entregados", value: byStatus["ENTREGADO"] ?? 0, color: "var(--green)" },
|
||||
{ label: "En tránsito", value: byStatus["EN_CAMINO_A_ECUADOR"] ?? 0, color: "var(--yellow)" },
|
||||
{ label: "Valor declarado", value: `$${totalDeclared.toLocaleString("es-EC")}`, color: "var(--accent)" },
|
||||
{ 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" },
|
||||
].map(s => (
|
||||
<div key={s.label} className="stat-card">
|
||||
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
|
||||
<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="grid-2" style={{ gap: "1.5rem" }}>
|
||||
{/* Paquetes por estado */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Paquetes por estado</span></div>
|
||||
<div className="card-header"><span className="font-semibold">Paquetes por estado (§08)</span></div>
|
||||
<div className="card-body">
|
||||
{Object.entries(byStatus).map(([status, count]) => (
|
||||
<div key={status} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
||||
<span style={{ fontSize: ".9rem" }}>{status.replace(/_/g," ")}</span>
|
||||
<span style={{ fontWeight: 700 }}>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(byStatus).length === 0 && <p style={{ color: "var(--gray-500)" }}>Sin datos.</p>}
|
||||
{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", flexShrink:0 }} />
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{packages.length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Usuarios por rol</span></div>
|
||||
<div className="card-body">
|
||||
{Object.entries(byRole).map(([role, count]) => (
|
||||
<div key={role} style={{ display: "flex", justifyContent: "space-between", padding: ".5rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
||||
<span style={{ fontSize: ".9rem" }}>{role}</span>
|
||||
<span style={{ fontWeight: 700 }}>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display:"flex", flexDirection:"column", gap:"1.5rem" }}>
|
||||
{/* Usuarios por rol */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Usuarios por rol (§06)</span></div>
|
||||
<div className="card-body">
|
||||
{Object.entries(byRole).map(([role, count]) => (
|
||||
<div key={role} style={{ display:"flex", justifyContent:"space-between", padding:".4rem 0", borderBottom:"1px solid var(--gray-100)" }}>
|
||||
<span style={{ fontSize:".875rem" }}>{role}</span>
|
||||
<span style={{ fontWeight:700 }}>{count}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(byRole).length === 0 && <p style={{ color:"var(--gray-500)", fontSize:".9rem" }}>Sin datos.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Métricas rápidas */}
|
||||
<div className="card">
|
||||
<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)}%` : "—"],
|
||||
["Pendiente declaración", 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>
|
||||
<span style={{ fontWeight:600 }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const STATUSES = ["RECIBIDO_EN_NJ","EN_PROCESO","EN_CAMINO_A_ECUADOR","EN_ADUANA","EN_BODEGA_EC","LISTO_PARA_RETIRO","ENTREGADO","RETENIDO_ADUANA","DEVUELTO","PERDIDO","CANCELADO"];
|
||||
// §08 — 11 estados oficiales del ciclo de vida del paquete
|
||||
const STATUSES = [
|
||||
"REGISTRADO",
|
||||
"EN_TRANSITO_BODEGA",
|
||||
"RECIBIDO_BODEGA",
|
||||
"EN_VERIFICACION",
|
||||
"VERIFICADO",
|
||||
"DECLARACION_ADUANERA",
|
||||
"EN_TRANSITO_ECUADOR",
|
||||
"EN_ADUANA_ECUADOR",
|
||||
"LISTO_ENTREGA",
|
||||
"ENTREGADO",
|
||||
"INCIDENCIA",
|
||||
];
|
||||
|
||||
export default function BodegaPaquetesPage() {
|
||||
const [packages, setPackages] = useState<any[]>([]);
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// §20 — /casillero/calculadora → redirige a /calculadora
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function CasilleroCalculadoraPage() {
|
||||
redirect("/calculadora");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// §20 — /casillero/registro → redirige a /registro
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function CasilleroRegistroPage() {
|
||||
redirect("/registro");
|
||||
}
|
||||
@@ -367,6 +367,99 @@ tbody tr:hover { background: var(--gray-50); }
|
||||
.address-box .address-key { font-weight: 500; color: var(--gray-700); min-width: 110px; }
|
||||
.address-box .address-val { font-family: monospace; color: var(--gray-900); }
|
||||
|
||||
/* ─── Toggle Switch ──────────────────────────────────── */
|
||||
.toggle-wrap { display: flex; align-items: center; gap: .5rem; cursor: pointer; user-select: none; }
|
||||
.toggle-wrap input[type="checkbox"] { display: none; }
|
||||
.toggle-track {
|
||||
position: relative; width: 36px; height: 20px; border-radius: 9999px;
|
||||
background: var(--gray-300); transition: background .2s; flex-shrink: 0;
|
||||
}
|
||||
.toggle-thumb {
|
||||
position: absolute; top: 2px; left: 2px;
|
||||
width: 16px; height: 16px; border-radius: 9999px;
|
||||
background: var(--white); box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
transition: transform .2s;
|
||||
}
|
||||
.toggle-wrap input:checked ~ .toggle-track { background: var(--primary); }
|
||||
.toggle-wrap input:checked ~ .toggle-track .toggle-thumb { transform: translateX(16px); }
|
||||
.toggle-label { font-size: .8rem; font-weight: 500; color: var(--gray-600); }
|
||||
|
||||
/* ─── Integration field row ──────────────────────────── */
|
||||
.int-field {
|
||||
display: grid; grid-template-columns: 20px 1fr auto auto;
|
||||
align-items: center; gap: 1rem;
|
||||
padding: .875rem 1rem; border-bottom: 1px solid var(--gray-100);
|
||||
}
|
||||
.int-field:last-child { border-bottom: none; }
|
||||
.int-field:hover { background: var(--gray-50); border-radius: var(--radius); }
|
||||
.int-dot { width: 10px; height: 10px; border-radius: 9999px; flex-shrink: 0; }
|
||||
.int-dot.has-value { background: var(--green); box-shadow: 0 0 0 3px var(--green-light); }
|
||||
.int-dot.no-value { background: var(--gray-300); }
|
||||
.int-label-wrap { min-width: 0; }
|
||||
.int-label { font-size: .875rem; font-weight: 500; color: var(--gray-800); display: flex; align-items: center; gap: .375rem; }
|
||||
.int-hint { font-size: .75rem; color: var(--gray-500); margin-top: .1rem; }
|
||||
.int-input-wrap { position: relative; width: 260px; flex-shrink: 0; }
|
||||
.int-input {
|
||||
width: 100%; padding: .5rem 2.5rem .5rem .75rem;
|
||||
border: 1.5px solid var(--gray-300); border-radius: var(--radius);
|
||||
font-size: .875rem; background: var(--white); color: var(--gray-900);
|
||||
transition: border-color .2s; font-family: monospace;
|
||||
}
|
||||
.int-input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.08); }
|
||||
.int-input.is-dirty { border-color: var(--accent); }
|
||||
.int-eye {
|
||||
position: absolute; right: .5rem; top: 50%; transform: translateY(-50%);
|
||||
background: none; border: none; cursor: pointer; color: var(--gray-400);
|
||||
font-size: .875rem; padding: .2rem; line-height: 1;
|
||||
}
|
||||
.int-eye:hover { color: var(--gray-700); }
|
||||
|
||||
/* ─── Page header (if not already defined) ───────────── */
|
||||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-title { font-size: 1.5rem; font-weight: 700; color: var(--gray-900); }
|
||||
.page-subtitle { font-size: .9rem; color: var(--gray-500); margin-top: .25rem; }
|
||||
|
||||
/* ─── Form control alias ─────────────────────────────── */
|
||||
.form-label { font-size: .8125rem; font-weight: 500; color: var(--gray-700); margin-bottom: .25rem; display: block; }
|
||||
.form-control {
|
||||
width: 100%; padding: .5rem .75rem;
|
||||
border: 1.5px solid var(--gray-300); border-radius: var(--radius);
|
||||
font-size: .9rem; background: var(--white); color: var(--gray-900); transition: border-color .2s;
|
||||
}
|
||||
.form-control:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px rgba(0,87,255,.08); }
|
||||
|
||||
/* ─── Modal ──────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 200;
|
||||
display: flex; align-items: center; justify-content: center; padding: 1rem;
|
||||
}
|
||||
.modal {
|
||||
background: var(--white); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl); width: 100%; max-height: 90vh; overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--gray-200);
|
||||
}
|
||||
.modal-header h3 { font-size: 1.05rem; font-weight: 700; }
|
||||
.modal-body { padding: 1.5rem; }
|
||||
.modal-footer {
|
||||
display: flex; justify-content: flex-end; gap: .75rem;
|
||||
padding: 1rem 1.5rem; border-top: 1px solid var(--gray-200); background: var(--gray-50);
|
||||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||||
}
|
||||
.btn-icon { background: none; border: none; cursor: pointer; color: var(--gray-500); font-size: 1rem; padding: .25rem; line-height: 1; border-radius: var(--radius); }
|
||||
.btn-icon:hover { background: var(--gray-100); color: var(--gray-800); }
|
||||
|
||||
/* ─── Empty state ────────────────────────────────────── */
|
||||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--gray-500); }
|
||||
|
||||
/* ─── Badge aliases ──────────────────────────────────── */
|
||||
.badge-success { background: var(--green-light); color: #065f46; }
|
||||
.badge-error { background: var(--red-light); color: #991b1b; }
|
||||
.badge-warning { background: var(--yellow-light); color: #92400e; }
|
||||
.badge-info { background: var(--blue-light); color: #1e40af; }
|
||||
|
||||
/* ─── Responsive ─────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { WhatsAppFloat } from "./_components/whatsapp-float";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Moraworld Imports — Tu casillero en New Jersey para Ecuador",
|
||||
@@ -445,6 +446,7 @@ export default function HomePage() {
|
||||
<RolesSection />
|
||||
<CompaniesSection />
|
||||
<Footer />
|
||||
<WhatsAppFloat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,96 +3,149 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// §08 — 11 estados oficiales
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
RECIBIDO_EN_NJ: "Recibido en NJ", EN_PROCESO: "En proceso",
|
||||
EN_CAMINO_A_ECUADOR: "En camino a Ecuador", EN_ADUANA: "En aduana",
|
||||
EN_BODEGA_EC: "En bodega EC", LISTO_PARA_RETIRO: "Listo para retiro",
|
||||
ENTREGADO: "Entregado", RETENIDO_ADUANA: "Retenido", DEVUELTO: "Devuelto",
|
||||
PERDIDO: "Perdido", CANCELADO: "Cancelado",
|
||||
REGISTRADO: "Registrado",
|
||||
EN_TRANSITO_BODEGA: "En tránsito a NJ",
|
||||
RECIBIDO_BODEGA: "Recibido en NJ",
|
||||
EN_VERIFICACION: "En verificación",
|
||||
VERIFICADO: "Verificado",
|
||||
DECLARACION_ADUANERA: "Declaración aduanera",
|
||||
EN_TRANSITO_ECUADOR: "En tránsito a Ecuador",
|
||||
EN_ADUANA_ECUADOR: "En aduana Ecuador",
|
||||
LISTO_ENTREGA: "Listo para entrega",
|
||||
ENTREGADO: "Entregado",
|
||||
INCIDENCIA: "Incidencia",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
RECIBIDO_EN_NJ: "badge-blue", EN_PROCESO: "badge-yellow",
|
||||
EN_CAMINO_A_ECUADOR: "badge-orange", EN_ADUANA: "badge-yellow",
|
||||
EN_BODEGA_EC: "badge-blue", LISTO_PARA_RETIRO: "badge-green",
|
||||
ENTREGADO: "badge-green", RETENIDO_ADUANA: "badge-red",
|
||||
DEVUELTO: "badge-red", PERDIDO: "badge-red", CANCELADO: "badge-gray",
|
||||
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",
|
||||
};
|
||||
|
||||
// Estados donde el cliente puede pagar
|
||||
const PAYABLE_STATUSES = ["VERIFICADO", "DECLARACION_ADUANERA"];
|
||||
|
||||
export default function MisPaquetesPage() {
|
||||
const [packages, setPackages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.packages.list().then(setPackages).catch(() => {}).finally(() => setLoading(false));
|
||||
api.packages.list()
|
||||
.then(setPackages)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = packages.filter(p =>
|
||||
!filter ||
|
||||
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
p.trackingNumber?.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
p.description?.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
|
||||
<div style={{ marginBottom: "1.5rem", display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: "1rem" }}>
|
||||
<div>
|
||||
<h1 className="dash-page-title">Mis Paquetes</h1>
|
||||
<p className="dash-page-subtitle">Historial completo de tus envíos.</p>
|
||||
</div>
|
||||
<input
|
||||
className="input" style={{ maxWidth: 280 }}
|
||||
className="form-input"
|
||||
style={{ maxWidth: 280 }}
|
||||
placeholder="Buscar por código o descripción..."
|
||||
value={filter} onChange={e => setFilter(e.target.value)}
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>📦</div>
|
||||
<p style={{ color: "var(--gray-500)" }}>{filter ? "No hay paquetes que coincidan." : "Aún no tienes paquetes registrados."}</p>
|
||||
<p style={{ color: "var(--gray-500)" }}>
|
||||
{filter ? "No hay paquetes que coincidan." : "Aún no tienes paquetes registrados."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{filtered.map(p => (
|
||||
<div key={p.id} className="card pointer" onClick={() => setSelected(p === selected ? null : p)}>
|
||||
<div key={p.id} className="card" style={{ cursor: "pointer" }} onClick={() => setSelected(p === selected ? null : p)}>
|
||||
<div className="card-body" style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "1rem" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontSize: "1rem", color: "var(--primary)" }}>{p.trackingId}</div>
|
||||
<div style={{ fontSize: ".875rem", color: "var(--gray-500)", marginTop: ".2rem" }}>
|
||||
{p.description ?? "Sin descripción"} · {p.trackingNumber ?? ""}
|
||||
{p.description ?? "Sin descripción"}
|
||||
{p.vendorTracking ? ` · ${p.vendorTracking}` : ""}
|
||||
</div>
|
||||
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
||||
{new Date(p.createdAt).toLocaleDateString("es-EC")}
|
||||
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span>
|
||||
{p.weightLb && <div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".4rem" }}>{p.weightLb} lb</div>}
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".5rem" }}>
|
||||
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>
|
||||
{STATUS_LABEL[p.status] ?? p.status}
|
||||
</span>
|
||||
{/* Botón de pago cuando corresponde */}
|
||||
{PAYABLE_STATUSES.includes(p.status) && !p.paidAt && (
|
||||
<Link
|
||||
href={`/portal/pago?packageId=${p.id}`}
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: ".8rem", padding: ".35rem .8rem" }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
💳 Pagar envío
|
||||
</Link>
|
||||
)}
|
||||
{p.paidAt && (
|
||||
<span style={{ fontSize: ".75rem", color: "var(--green)", fontWeight: 600 }}>✓ Pagado</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Historial expandido */}
|
||||
|
||||
{/* Detalle expandido */}
|
||||
{selected?.id === p.id && (
|
||||
<div className="card-footer">
|
||||
<div style={{ fontWeight: 600, marginBottom: ".75rem" }}>Historial de estados</div>
|
||||
<div className="card-footer" style={{ borderTop: "1px solid var(--gray-100)", paddingTop: "1rem" }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: ".75rem", fontSize: ".9rem" }}>Historial de estados</div>
|
||||
{p.statusHistory?.length ? (
|
||||
<div className="timeline">
|
||||
{p.statusHistory.map((h: any, i: number) => (
|
||||
<div key={h.id} className="timeline-item">
|
||||
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{STATUS_LABEL[h.status] ?? h.status}</div>
|
||||
{h.notes && <div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.notes}</div>}
|
||||
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>{new Date(h.createdAt).toLocaleString("es-EC")}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>
|
||||
{STATUS_LABEL[h.status] ?? h.status}
|
||||
</div>
|
||||
{h.note && (
|
||||
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>{h.note}</div>
|
||||
)}
|
||||
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
||||
{new Date(h.createdAt).toLocaleString("es-EC")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial.</p>}
|
||||
) : (
|
||||
<p style={{ fontSize: ".875rem", color: "var(--gray-500)" }}>Sin historial disponible.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
import { useEffect, useState, Suspense } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api, getUser } from "@/lib/api";
|
||||
|
||||
// §09 paso 8 — Página de pago del envío
|
||||
// URL: /portal/pago?packageId=xxx
|
||||
|
||||
const STATUS_LABEL: Record<string, { text: string; color: string }> = {
|
||||
PENDIENTE: { text: "Pendiente de pago", color: "var(--yellow)" },
|
||||
PROCESANDO: { text: "Procesando...", color: "var(--accent)" },
|
||||
COMPLETADO: { text: "Pago completado", color: "var(--green)" },
|
||||
FALLIDO: { text: "Pago fallido", color: "var(--red)" },
|
||||
REEMBOLSADO: { text: "Reembolsado", color: "var(--gray-500)" },
|
||||
};
|
||||
|
||||
function PagoContent() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const packageId = params.get("packageId");
|
||||
const user = getUser();
|
||||
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) { router.replace("/login"); return; }
|
||||
if (!packageId) { router.replace("/portal/mis-paquetes"); return; }
|
||||
api.payments.packageDetail(packageId)
|
||||
.then(setData)
|
||||
.catch(() => setError("No se pudo cargar el paquete."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [packageId, router, user]);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!data) return;
|
||||
setPaying(true);
|
||||
setError(null);
|
||||
try {
|
||||
// 1. Crear intent de pago
|
||||
const intent = await api.payments.createIntent(data.package.id);
|
||||
// 2. En producción aquí se abre Stripe Checkout/PayPhone; en dev confirmamos directamente
|
||||
await api.payments.confirm(intent.id);
|
||||
setSuccess(true);
|
||||
// Recargar datos
|
||||
const fresh = await api.payments.packageDetail(packageId!);
|
||||
setData(fresh);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Error al procesar el pago.");
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", minHeight: "60vh" }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !data) {
|
||||
return (
|
||||
<div style={{ maxWidth: 480, margin: "4rem auto", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "2rem", marginBottom: ".75rem" }}>⚠️</div>
|
||||
<p style={{ color: "var(--gray-600)" }}>{error}</p>
|
||||
<button className="btn btn-ghost" style={{ marginTop: "1rem" }} onClick={() => router.back()}>Volver</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { package: pkg, payment, breakdown } = data ?? {};
|
||||
const payStatus = payment?.status;
|
||||
const alreadyPaid = payStatus === "COMPLETADO";
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 560, margin: "0 auto", padding: "2rem 1rem" }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ marginBottom: "1.5rem", fontSize: ".85rem" }}
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
← Volver
|
||||
</button>
|
||||
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: ".25rem" }}>Pago del envío</h1>
|
||||
<p style={{ color: "var(--gray-500)", fontSize: ".9rem", marginBottom: "1.5rem" }}>
|
||||
Tracking: <code style={{ fontWeight: 700, color: "var(--primary)" }}>{pkg?.trackingId}</code>
|
||||
</p>
|
||||
|
||||
{/* Estado actual del pago */}
|
||||
{payment && (
|
||||
<div style={{
|
||||
display: "flex", alignItems: "center", gap: ".75rem",
|
||||
padding: ".875rem 1rem", borderRadius: 8, marginBottom: "1.5rem",
|
||||
background: "var(--gray-50)", border: "1px solid var(--gray-200)",
|
||||
}}>
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: "50%", flexShrink: 0,
|
||||
background: STATUS_LABEL[payStatus]?.color ?? "var(--gray-400)",
|
||||
}} />
|
||||
<span style={{ fontWeight: 600 }}>{STATUS_LABEL[payStatus]?.text ?? payStatus}</span>
|
||||
{payment.paidAt && (
|
||||
<span style={{ marginLeft: "auto", fontSize: ".8rem", color: "var(--gray-500)" }}>
|
||||
{new Date(payment.paidAt).toLocaleString("es-EC")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desglose de costos */}
|
||||
<div className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<div className="card-header"><span style={{ fontWeight: 700 }}>Desglose de costos (§15)</span></div>
|
||||
<div className="card-body">
|
||||
{[
|
||||
["Peso", `${breakdown?.weightLb ?? 0} lb`],
|
||||
["Precio por libra", `$${breakdown?.pricePerLb ?? 0}`],
|
||||
["Flete", `$${breakdown?.freight ?? 0}`],
|
||||
["Seguro (2%)", `$${breakdown?.insurance ?? 0}`],
|
||||
["FODINFA (0.5%)", `$${breakdown?.fodinfa ?? 0}`],
|
||||
].map(([k, v]) => (
|
||||
<div key={k as string} style={{ display: "flex", justifyContent: "space-between", padding: ".4rem 0", borderBottom: "1px solid var(--gray-100)" }}>
|
||||
<span style={{ color: "var(--gray-600)", fontSize: ".9rem" }}>{k}</span>
|
||||
<span style={{ fontSize: ".9rem" }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: ".75rem 0 0", marginTop: ".25rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: "1rem" }}>Total a pagar</span>
|
||||
<span style={{ fontWeight: 700, fontSize: "1.15rem", color: "var(--primary)" }}>
|
||||
${breakdown?.total ?? 0} USD
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Descripción del paquete */}
|
||||
<div style={{ padding: ".875rem 1rem", background: "var(--gray-50)", borderRadius: 8, marginBottom: "1.5rem", fontSize: ".875rem", color: "var(--gray-600)" }}>
|
||||
<strong>Descripción:</strong> {pkg?.description}<br />
|
||||
<strong>Valor declarado:</strong> ${pkg?.declaredValue} USD
|
||||
</div>
|
||||
|
||||
{/* Alerta de éxito */}
|
||||
{(success || alreadyPaid) && (
|
||||
<div style={{ background: "#F0FDF4", border: "1px solid #86EFAC", borderRadius: 8, padding: "1rem", marginBottom: "1.5rem", display: "flex", gap: ".75rem", alignItems: "flex-start" }}>
|
||||
<span style={{ fontSize: "1.3rem" }}>✅</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: "#166534" }}>¡Pago completado!</div>
|
||||
<div style={{ fontSize: ".85rem", color: "#15803D" }}>Tu envío fue confirmado y está siendo procesado.</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div style={{ background: "#FEF2F2", border: "1px solid #FCA5A5", borderRadius: 8, padding: ".875rem 1rem", marginBottom: "1rem", fontSize: ".875rem", color: "#991B1B" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botón de pago */}
|
||||
{!alreadyPaid && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%", padding: "1rem", fontSize: "1rem", fontWeight: 700 }}
|
||||
onClick={handlePay}
|
||||
disabled={paying}
|
||||
>
|
||||
{paying ? "Procesando pago..." : `Pagar $${breakdown?.total ?? 0} USD`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{alreadyPaid && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: "100%" }}
|
||||
onClick={() => router.push("/portal/mis-paquetes")}
|
||||
>
|
||||
Ver mis paquetes
|
||||
</button>
|
||||
)}
|
||||
|
||||
<p style={{ marginTop: "1rem", fontSize: ".75rem", color: "var(--gray-400)", textAlign: "center" }}>
|
||||
Pago seguro · Stripe / PayPhone · Los datos de tu tarjeta nunca se almacenan en nuestros servidores.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PagoPage() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}><div className="spinner" /></div>}>
|
||||
<PagoContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -154,4 +154,19 @@ export const api = {
|
||||
batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) =>
|
||||
request<any>("/integrations/batch", { method: "PUT", body: JSON.stringify({ items }) }),
|
||||
},
|
||||
notificationTemplates: {
|
||||
list: () => request<any[]>("/notification-templates"),
|
||||
seed: () => request<any>("/notification-templates/seed", { method: "POST" }),
|
||||
update: (id: string, body: { body: string; subject?: string; isActive?: boolean }) =>
|
||||
request<any>(`/notification-templates/${id}`, { method: "PUT", body: JSON.stringify(body) }),
|
||||
},
|
||||
payments: {
|
||||
list: (status?: string) => request<any[]>(`/payments${status ? `?status=${status}` : ""}`),
|
||||
packageDetail: (packageId: string) => request<any>(`/payments/package/${packageId}`),
|
||||
byTracking: (trackingId: string) => request<any>(`/payments/track/${trackingId}`),
|
||||
createIntent: (packageId: string, provider = "stripe") =>
|
||||
request<any>("/payments/intent", { method: "POST", body: JSON.stringify({ packageId, provider }) }),
|
||||
confirm: (paymentId: string) =>
|
||||
request<any>(`/payments/${paymentId}/confirm`, { method: "POST" }),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user