From b2f03e654f9d3f9b0edfb7f8e6e1752c048d78c4 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 18:35:46 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20timestamp=20component=20(=C2=A719),=20a?= =?UTF-8?q?dmin=20B2B=20page=20(=C2=A713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## §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) --- apps/web/src/app/_components/timestamp.tsx | 62 ++++ apps/web/src/app/admin/auditoria/page.tsx | 6 +- apps/web/src/app/admin/b2b/page.tsx | 331 ++++++++++++++++++ apps/web/src/app/admin/layout.tsx | 1 + apps/web/src/app/portal/mis-paquetes/page.tsx | 13 +- apps/web/src/app/portal/pago/page.tsx | 9 +- 6 files changed, 410 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/app/_components/timestamp.tsx create mode 100644 apps/web/src/app/admin/b2b/page.tsx diff --git a/apps/web/src/app/_components/timestamp.tsx b/apps/web/src/app/_components/timestamp.tsx new file mode 100644 index 0000000..5898ba0 --- /dev/null +++ b/apps/web/src/app/_components/timestamp.tsx @@ -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 {formatted}; +} diff --git a/apps/web/src/app/admin/auditoria/page.tsx b/apps/web/src/app/admin/auditoria/page.tsx index 7885e20..93454e4 100644 --- a/apps/web/src/app/admin/auditoria/page.tsx +++ b/apps/web/src/app/admin/auditoria/page.tsx @@ -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([]); @@ -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() { Sin registros. ) : logs.map(l => ( - {fmt(l.createdAt)} + + + {l.action} {l.resource ?? "—"} {l.resourceId?.slice(0,12) ?? "—"} diff --git a/apps/web/src/app/admin/b2b/page.tsx b/apps/web/src/app/admin/b2b/page.tsx new file mode 100644 index 0000000..7948266 --- /dev/null +++ b/apps/web/src/app/admin/b2b/page.tsx @@ -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 = { + PENDIENTE: "Pendiente", + CONTACTADO: "Contactado", + COTIZADO: "Cotizado", + APROBADO: "Aprobado", + EN_PROCESO: "En proceso", + COMPLETADO: "Completado", + CANCELADO: "Cancelado", +}; + +const STATUS_BADGE: Record = { + 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([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + const [updating, setUpdating] = useState(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 ( +
+
+

Solicitudes B2B

+

Importadores mayoristas y empresas · §13

+
+ + {/* KPIs */} +
+ {KPIS.map(k => ( +
+
{k.label}
+
{k.value}
+
+ ))} +
+ + {/* Filters */} +
+
+ setFilter(e.target.value)} + /> + + +
+
+ + {/* Table */} + {loading ? ( +
+
+
+ ) : filtered.length === 0 ? ( +
+
🏭
+

+ {filter || statusFilter ? "Sin solicitudes que coincidan con los filtros." : "No hay solicitudes B2B registradas."} +

+
+ ) : ( +
+
+ + + + + + + + + + + + + + + {filtered.map(r => ( + + + + + + + + + + + ))} + +
IDEmpresa / ContactoMercancíaValor declaradoCotizaciónEstadoFechaAcciones
+ + {r.trackingId} + + +
+ {r.companyName || r.contactName} +
+
{r.contactEmail}
+ {r.contactPhone && ( +
{r.contactPhone}
+ )} +
+
{r.merchandiseType}
+
+ {r.description} +
+
+ {r.commercialValue != null ? `$${Number(r.commercialValue).toLocaleString("es-EC", { minimumFractionDigits: 2 })}` : "—"} + + {r.quotationAmount != null ? ( + + ${Number(r.quotationAmount).toLocaleString("es-EC", { minimumFractionDigits: 2 })} + + ) : "—"} + + + {STATUS_LABEL[r.status] ?? r.status} + + + + + +
+
+
+ )} + + {/* Modal de gestión */} + {showModal && selected && ( +
+
+
+ Gestionar solicitud {selected.trackingId} + +
+
+ + {/* Info del solicitante */} +
+
Solicitante
+
Empresa: {selected.companyName || "—"}
+
Contacto: {selected.contactName}
+
Email: {selected.contactEmail}
+ {selected.contactPhone &&
Teléfono: {selected.contactPhone}
} +
+
+
Mercancía
+
Tipo: {selected.merchandiseType}
+
{selected.description}
+ {selected.commercialValue && ( +
Valor declarado: ${Number(selected.commercialValue).toFixed(2)}
+ )} +
+ + {/* Cambio de estado */} +
+ + +
+ + {/* Monto de cotización */} +
+ + setModalForm(f => ({ ...f, quotationAmount: e.target.value }))} + /> +
+ + {/* Notas */} +
+ +