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:
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user