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";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { Timestamp } from "@/app/_components/timestamp";
|
||||||
|
|
||||||
export default function AuditoriaPage() {
|
export default function AuditoriaPage() {
|
||||||
const [logs, setLogs] = useState<any[]>([]);
|
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",
|
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);
|
const pages = Math.ceil(total / LIMIT);
|
||||||
|
|
||||||
return (
|
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>
|
<tr><td colSpan={6} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin registros.</td></tr>
|
||||||
) : logs.map(l => (
|
) : logs.map(l => (
|
||||||
<tr key={l.id}>
|
<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><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">{l.resource ?? "—"}</td>
|
||||||
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem", color: "var(--gray-500)" }}>{l.resourceId?.slice(0,12) ?? "—"}</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/usuarios", icon: "👥", label: "Usuarios" },
|
||||||
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
||||||
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
|
{ href: "/admin/pagos", icon: "💳", label: "Cobros" },
|
||||||
|
{ href: "/admin/b2b", icon: "🏭", label: "B2B" },
|
||||||
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
|
{ href: "/admin/notificaciones", icon: "📋", label: "Notificaciones" },
|
||||||
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
||||||
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { Timestamp } from "@/app/_components/timestamp";
|
||||||
|
|
||||||
// §08 — 11 estados oficiales
|
// §08 — 11 estados oficiales
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
@@ -94,10 +95,10 @@ export default function MisPaquetesPage() {
|
|||||||
{p.description ?? "Sin descripción"}
|
{p.description ?? "Sin descripción"}
|
||||||
{p.vendorTracking ? ` · ${p.vendorTracking}` : ""}
|
{p.vendorTracking ? ` · ${p.vendorTracking}` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
<div style={{ fontSize: ".8rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
||||||
{new Date(p.createdAt).toLocaleDateString("es-EC")}
|
<Timestamp value={p.createdAt} dateOnly />
|
||||||
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
|
{p.actualWeight ? ` · ${p.actualWeight} lb` : p.declaredWeight ? ` · ~${p.declaredWeight} lb (declarado)` : ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".5rem" }}>
|
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".5rem" }}>
|
||||||
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>
|
<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: ".8rem", color: "var(--gray-500)" }}>{h.note}</div>
|
||||||
)}
|
)}
|
||||||
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
|
||||||
{new Date(h.createdAt).toLocaleString("es-EC")}
|
<Timestamp value={h.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useEffect, useState, Suspense } from "react";
|
import { useEffect, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { api, getUser } from "@/lib/api";
|
import { api, getUser } from "@/lib/api";
|
||||||
|
import { Timestamp } from "@/app/_components/timestamp";
|
||||||
|
|
||||||
// §09 paso 8 — Página de pago del envío
|
// §09 paso 8 — Página de pago del envío
|
||||||
// URL: /portal/pago?packageId=xxx
|
// URL: /portal/pago?packageId=xxx
|
||||||
@@ -105,10 +106,10 @@ function PagoContent() {
|
|||||||
}} />
|
}} />
|
||||||
<span style={{ fontWeight: 600 }}>{STATUS_LABEL[payStatus]?.text ?? payStatus}</span>
|
<span style={{ fontWeight: 600 }}>{STATUS_LABEL[payStatus]?.text ?? payStatus}</span>
|
||||||
{payment.paidAt && (
|
{payment.paidAt && (
|
||||||
<span style={{ marginLeft: "auto", fontSize: ".8rem", color: "var(--gray-500)" }}>
|
<span style={{ marginLeft: "auto", fontSize: ".8rem", color: "var(--gray-500)" }}>
|
||||||
{new Date(payment.paidAt).toLocaleString("es-EC")}
|
<Timestamp value={payment.paidAt} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user