feat: timestamp component (§19), admin B2B page (§13)

## §19 Zona horaria
- New _components/timestamp.tsx: Intl.DateTimeFormat-based, auto-detects
  timezone from user role in localStorage
  · OPERADOR_BODEGA → America/New_York (EST/EDT)
  · All others → America/Guayaquil (ECT)
- Integrated in portal/mis-paquetes (list dates + status history)
- Integrated in portal/pago (paidAt timestamp)
- Integrated in admin/auditoria (replaces hardcoded toLocaleString)

## §13 B2B Admin page (/admin/b2b)
- Full admin UI for B2B import requests
- KPIs: total, pendientes, cotizados, aprobados, valor total cotizaciones
- Table with search + status filter
- Gestionar modal: change status, enter quotation amount + notes
- Uses api.b2b.list() + api.b2b.updateStatus()
- Added B2B link to admin sidebar nav

Build: clean (Next.js, no TS errors)
This commit is contained in:
Lizandro Guarnizo
2026-06-01 18:35:46 -05:00
parent 7682b11dff
commit b2f03e654f
6 changed files with 410 additions and 12 deletions
@@ -0,0 +1,62 @@
"use client";
/**
* §19 Zona horaria — muestra fechas en la zona del usuario autenticado.
* OPERADOR_BODEGA y usuarios NJ → America/New_York (EST/EDT)
* Resto (clientes Ecuador, admins) → America/Guayaquil (ECT)
*
* Se determina por el rol guardado en localStorage ("user" JSON).
*/
import { useMemo } from "react";
const NJ_ROLES = ["OPERADOR_BODEGA"];
function getUserTimezone(): string {
try {
const raw = typeof window !== "undefined" ? localStorage.getItem("user") : null;
if (raw) {
const u = JSON.parse(raw) as { role?: string };
if (u.role && NJ_ROLES.includes(u.role)) return "America/New_York";
}
} catch {}
return "America/Guayaquil";
}
interface TimestampProps {
/** ISO 8601 date string or Date object */
value: string | Date | null | undefined;
/** Show date only (no time). Default false. */
dateOnly?: boolean;
/** Force a specific IANA timezone instead of auto-detecting. */
tz?: string;
/** Fallback text when value is null/undefined */
fallback?: string;
}
export function Timestamp({ value, dateOnly = false, tz, fallback = "—" }: TimestampProps) {
const formatted = useMemo(() => {
if (!value) return fallback;
const date = typeof value === "string" ? new Date(value) : value;
if (isNaN(date.getTime())) return fallback;
const timezone = tz ?? getUserTimezone();
const tzLabel = timezone === "America/New_York" ? "EST" : "ECT";
const opts: Intl.DateTimeFormatOptions = dateOnly
? { year: "numeric", month: "short", day: "numeric", timeZone: timezone }
: {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: timezone,
};
const formatted = new Intl.DateTimeFormat("es-EC", opts).format(date);
return dateOnly ? formatted : `${formatted} (${tzLabel})`;
}, [value, dateOnly, tz, fallback]);
return <span title={value ? String(value) : undefined}>{formatted}</span>;
}
+4 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
export default function AuditoriaPage() {
const [logs, setLogs] = useState<any[]>([]);
@@ -41,7 +42,6 @@ export default function AuditoriaPage() {
MFA_ENABLED: "badge-blue", MFA_FAILED: "badge-red", USER_REGISTER: "badge-green",
};
const fmt = (d: string) => new Date(d).toLocaleString("es-EC", { timeZone: "America/Guayaquil" });
const pages = Math.ceil(total / LIMIT);
return (
@@ -101,7 +101,9 @@ export default function AuditoriaPage() {
<tr><td colSpan={6} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin registros.</td></tr>
) : logs.map(l => (
<tr key={l.id}>
<td style={{ fontSize: ".78rem", fontFamily: "monospace", whiteSpace: "nowrap" }}>{fmt(l.createdAt)}</td>
<td style={{ fontSize: ".78rem", fontFamily: "monospace", whiteSpace: "nowrap" }}>
<Timestamp value={l.createdAt} tz="America/Guayaquil" />
</td>
<td><span className={`badge ${ACTION_COLORS[l.action] ?? "badge-gray"}`} style={{ fontSize: ".72rem" }}>{l.action}</span></td>
<td className="text-sm">{l.resource ?? "—"}</td>
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem", color: "var(--gray-500)" }}>{l.resourceId?.slice(0,12) ?? "—"}</td>
+331
View File
@@ -0,0 +1,331 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { api } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
// §13 — Flujo del Importador Mayorista (B2B)
const STATUS_LABEL: Record<string, string> = {
PENDIENTE: "Pendiente",
CONTACTADO: "Contactado",
COTIZADO: "Cotizado",
APROBADO: "Aprobado",
EN_PROCESO: "En proceso",
COMPLETADO: "Completado",
CANCELADO: "Cancelado",
};
const STATUS_BADGE: Record<string, string> = {
PENDIENTE: "badge-yellow",
CONTACTADO: "badge-blue",
COTIZADO: "badge-blue",
APROBADO: "badge-green",
EN_PROCESO: "badge-orange",
COMPLETADO: "badge-green",
CANCELADO: "badge-red",
};
const STATUS_FLOW = ["PENDIENTE", "CONTACTADO", "COTIZADO", "APROBADO", "EN_PROCESO", "COMPLETADO", "CANCELADO"];
export default function AdminB2BPage() {
const [requests, setRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<any | null>(null);
const [updating, setUpdating] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
// Modal state
const [showModal, setShowModal] = useState(false);
const [modalForm, setModalForm] = useState({ status: "", quotationNotes: "", quotationAmount: "" });
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
const load = useCallback(() => {
setLoading(true);
api.b2b.list()
.then(setRequests)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => { load(); }, [load]);
const filtered = requests.filter(r => {
const matchSearch =
!filter ||
r.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
r.contactName?.toLowerCase().includes(filter.toLowerCase()) ||
r.companyName?.toLowerCase().includes(filter.toLowerCase()) ||
r.contactEmail?.toLowerCase().includes(filter.toLowerCase());
const matchStatus = !statusFilter || r.status === statusFilter;
return matchSearch && matchStatus;
});
// KPIs
const total = requests.length;
const pendiente = requests.filter(r => r.status === "PENDIENTE").length;
const cotizado = requests.filter(r => r.status === "COTIZADO").length;
const aprobado = requests.filter(r => r.status === "APROBADO").length;
const totalValue = requests
.filter(r => r.quotationAmount)
.reduce((s, r) => s + Number(r.quotationAmount), 0);
const openModal = (r: any) => {
setSelected(r);
setModalForm({
status: r.status,
quotationNotes: r.quotationNotes ?? "",
quotationAmount: r.quotationAmount != null ? String(r.quotationAmount) : "",
});
setShowModal(true);
setMsg(null);
};
const handleSave = async () => {
if (!selected) return;
setSaving(true);
try {
await api.b2b.updateStatus(selected.id, {
status: modalForm.status,
quotationNotes: modalForm.quotationNotes || undefined,
quotationAmount: modalForm.quotationAmount ? Number(modalForm.quotationAmount) : undefined,
});
setMsg({ type: "success", text: "Solicitud actualizada." });
load();
setTimeout(() => { setShowModal(false); setMsg(null); }, 800);
} catch {
setMsg({ type: "error", text: "Error al actualizar. Intenta de nuevo." });
} finally {
setSaving(false);
}
};
const KPIS = [
{ label: "Total solicitudes", value: total, color: "var(--primary)" },
{ label: "Pendientes", value: pendiente, color: "var(--warning)" },
{ label: "Cotizados", value: cotizado, color: "var(--accent)" },
{ label: "Aprobados", value: aprobado, color: "var(--green)" },
{ label: "Valor cotizaciones", value: `$${totalValue.toLocaleString("es-EC", { minimumFractionDigits: 2 })}`, color: "var(--accent)" },
];
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Solicitudes B2B</h1>
<p className="dash-page-subtitle">Importadores mayoristas y empresas · §13</p>
</div>
{/* KPIs */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(160px, 1fr))", gap: "1rem", marginBottom: "2rem" }}>
{KPIS.map(k => (
<div key={k.label} className="card" style={{ padding: "1.25rem" }}>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginBottom: ".5rem" }}>{k.label}</div>
<div style={{ fontSize: "1.5rem", fontWeight: 800, color: k.color }}>{k.value}</div>
</div>
))}
</div>
{/* Filters */}
<div className="card" style={{ padding: "1rem", marginBottom: "1.5rem" }}>
<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<input
className="form-input"
style={{ flex: 1, minWidth: 220 }}
placeholder="Buscar por ID, nombre, empresa o email..."
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<select
className="form-input"
style={{ minWidth: 180 }}
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
<option value="">Todos los estados</option>
{STATUS_FLOW.map(s => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
<button className="btn btn-ghost btn-sm" onClick={() => { setFilter(""); setStatusFilter(""); }}>
Limpiar
</button>
</div>
</div>
{/* Table */}
{loading ? (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<div className="spinner" />
</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 || statusFilter ? "Sin solicitudes que coincidan con los filtros." : "No hay solicitudes B2B registradas."}
</p>
</div>
) : (
<div className="card">
<div style={{ overflowX: "auto" }}>
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Empresa / Contacto</th>
<th>Mercancía</th>
<th>Valor declarado</th>
<th>Cotización</th>
<th>Estado</th>
<th>Fecha</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{filtered.map(r => (
<tr key={r.id}>
<td>
<span style={{ fontFamily: "monospace", fontSize: ".8rem", fontWeight: 700, color: "var(--primary)" }}>
{r.trackingId}
</span>
</td>
<td>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>
{r.companyName || r.contactName}
</div>
<div style={{ fontSize: ".75rem", color: "var(--gray-500)" }}>{r.contactEmail}</div>
{r.contactPhone && (
<div style={{ fontSize: ".75rem", color: "var(--gray-400)" }}>{r.contactPhone}</div>
)}
</td>
<td style={{ maxWidth: 200 }}>
<div style={{ fontWeight: 600, fontSize: ".875rem" }}>{r.merchandiseType}</div>
<div style={{ fontSize: ".75rem", color: "var(--gray-500)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{r.description}
</div>
</td>
<td style={{ fontFamily: "monospace", fontSize: ".875rem" }}>
{r.commercialValue != null ? `$${Number(r.commercialValue).toLocaleString("es-EC", { minimumFractionDigits: 2 })}` : "—"}
</td>
<td style={{ fontFamily: "monospace", fontSize: ".875rem" }}>
{r.quotationAmount != null ? (
<span style={{ color: "var(--green)", fontWeight: 700 }}>
${Number(r.quotationAmount).toLocaleString("es-EC", { minimumFractionDigits: 2 })}
</span>
) : "—"}
</td>
<td>
<span className={`badge ${STATUS_BADGE[r.status] ?? "badge-gray"}`}>
{STATUS_LABEL[r.status] ?? r.status}
</span>
</td>
<td style={{ fontSize: ".78rem", color: "var(--gray-500)", whiteSpace: "nowrap" }}>
<Timestamp value={r.createdAt} dateOnly tz="America/Guayaquil" />
</td>
<td>
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: ".78rem" }}
onClick={() => openModal(r)}
disabled={updating === r.id}
>
Gestionar
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Modal de gestión */}
{showModal && selected && (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,.5)", display: "flex",
alignItems: "center", justifyContent: "center", zIndex: 1000, padding: "1rem",
}}>
<div className="card" style={{ width: "100%", maxWidth: 540, maxHeight: "90vh", overflowY: "auto" }}>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 700 }}>Gestionar solicitud {selected.trackingId}</span>
<button className="btn btn-ghost btn-sm" onClick={() => setShowModal(false)}></button>
</div>
<div className="card-body" style={{ display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{/* Info del solicitante */}
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius)", padding: "1rem", fontSize: ".875rem" }}>
<div style={{ fontWeight: 600, marginBottom: ".5rem" }}>Solicitante</div>
<div><strong>Empresa:</strong> {selected.companyName || "—"}</div>
<div><strong>Contacto:</strong> {selected.contactName}</div>
<div><strong>Email:</strong> {selected.contactEmail}</div>
{selected.contactPhone && <div><strong>Teléfono:</strong> {selected.contactPhone}</div>}
</div>
<div style={{ background: "var(--gray-50)", borderRadius: "var(--radius)", padding: "1rem", fontSize: ".875rem" }}>
<div style={{ fontWeight: 600, marginBottom: ".5rem" }}>Mercancía</div>
<div><strong>Tipo:</strong> {selected.merchandiseType}</div>
<div style={{ marginTop: ".25rem" }}>{selected.description}</div>
{selected.commercialValue && (
<div style={{ marginTop: ".25rem" }}><strong>Valor declarado:</strong> ${Number(selected.commercialValue).toFixed(2)}</div>
)}
</div>
{/* Cambio de estado */}
<div>
<label className="form-label">Estado</label>
<select
className="form-input"
value={modalForm.status}
onChange={e => setModalForm(f => ({ ...f, status: e.target.value }))}
>
{STATUS_FLOW.map(s => (
<option key={s} value={s}>{STATUS_LABEL[s]}</option>
))}
</select>
</div>
{/* Monto de cotización */}
<div>
<label className="form-label">Monto cotizado (USD)</label>
<input
type="number"
min="0"
step="0.01"
className="form-input"
placeholder="0.00"
value={modalForm.quotationAmount}
onChange={e => setModalForm(f => ({ ...f, quotationAmount: e.target.value }))}
/>
</div>
{/* Notas */}
<div>
<label className="form-label">Notas de cotización</label>
<textarea
className="form-input"
rows={4}
placeholder="Detalles del flete, condiciones, requisitos INEN, notas de aduana..."
value={modalForm.quotationNotes}
onChange={e => setModalForm(f => ({ ...f, quotationNotes: e.target.value }))}
style={{ resize: "vertical", fontFamily: "inherit" }}
/>
</div>
{msg && (
<div className={`alert alert-${msg.type === "success" ? "success" : "error"}`}>{msg.text}</div>
)}
<div style={{ display: "flex", gap: ".75rem", justifyContent: "flex-end" }}>
<button className="btn btn-ghost" onClick={() => setShowModal(false)} disabled={saving}>
Cancelar
</button>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? "Guardando..." : "Guardar cambios"}
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
+1
View File
@@ -10,6 +10,7 @@ const NAV = [
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
{ href: "/admin/b2b", icon: "🏭", label: "B2B" },
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
// §08 — 11 estados oficiales
const STATUS_LABEL: Record<string, string> = {
@@ -94,10 +95,10 @@ export default function MisPaquetesPage() {
{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 style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
<Timestamp value={p.createdAt} dateOnly />
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".5rem" }}>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>
@@ -137,8 +138,8 @@ export default function MisPaquetesPage() {
<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>
<Timestamp value={h.createdAt} />
</div>
</div>
</div>
))}
+5 -4
View File
@@ -2,6 +2,7 @@
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { api, getUser } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
// §09 paso 8 — Página de pago del envío
// URL: /portal/pago?packageId=xxx
@@ -105,10 +106,10 @@ function PagoContent() {
}} />
<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>
)}
<span style={{ marginLeft: "auto", fontSize: ".8rem", color: "var(--gray-500)" }}>
<Timestamp value={payment.paidAt} />
</span>
)}
</div>
)}