feat: StorageService S3/MinIO, POST /users, invoice upload, portal dashboard §08 statuses + notifications

This commit is contained in:
Lizandro Guarnizo
2026-06-01 20:55:30 -05:00
parent 98ab5a309c
commit a047a8b032
19 changed files with 1166 additions and 134 deletions
+118 -10
View File
@@ -4,13 +4,24 @@ import { api } from "@/lib/api";
const ROLES = ["CLIENTE","OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN","SOPORTE"];
const BLANK_FORM = { email: "", password: "", firstName: "", lastName: "", phone: "", role: "CLIENTE" };
export default function UsuariosPage() {
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [updating, setUpdating] = useState<string | null>(null);
const load = (s?: string) => { setLoading(true); api.users.list(s).then(setUsers).catch(()=>{}).finally(()=>setLoading(false)); };
// Create modal
const [showModal, setShowModal] = useState(false);
const [form, setForm] = useState({ ...BLANK_FORM });
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState("");
const load = (s?: string) => {
setLoading(true);
api.users.list(s).then(setUsers).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => { load(); }, []);
const handleRoleChange = async (id: string, role: string) => {
@@ -25,23 +36,62 @@ export default function UsuariosPage() {
finally { setUpdating(null); }
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
setCreating(true); setCreateError("");
try {
await api.users.create({
email: form.email,
password: form.password,
firstName: form.firstName,
lastName: form.lastName,
phone: form.phone || undefined,
role: form.role,
});
setShowModal(false);
setForm({ ...BLANK_FORM });
load();
} catch (err: any) {
setCreateError(err.message ?? "Error al crear usuario");
} finally { setCreating(false); }
};
const setF = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div>
<div className="mb-6 flex justify-between items-center flex-wrap gap-4">
<div><h1 className="dash-page-title">Usuarios</h1><p className="dash-page-subtitle">Gestión de cuentas y roles.</p></div>
<div>
<h1 className="dash-page-title">Usuarios</h1>
<p className="dash-page-subtitle">Gestión de cuentas y roles.</p>
</div>
<div style={{ display: "flex", gap: ".75rem" }}>
<input className="input" style={{ maxWidth: 260 }} placeholder="Buscar por nombre o email…"
<input className="input" style={{ maxWidth: 240 }} placeholder="Buscar por nombre o email…"
value={search} onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === "Enter" && load(search)} />
<button className="btn btn-primary" onClick={() => load(search)}>Buscar</button>
<button className="btn btn-outline" onClick={() => load(search)}>Buscar</button>
<button className="btn btn-primary" onClick={() => { setCreateError(""); setForm({ ...BLANK_FORM }); setShowModal(true); }}>
+ Nuevo usuario
</button>
</div>
</div>
<div className="card">
<div className="table-wrap" style={{ borderRadius: "var(--radius-lg)", border: "none" }}>
{loading ? <div style={{ padding: "3rem", textAlign: "center" }}><div className="spinner mx-auto" /></div> : (
{loading ? (
<div style={{ padding: "3rem", textAlign: "center" }}><div className="spinner mx-auto" /></div>
) : users.length === 0 ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--gray-500)" }}>
No se encontraron usuarios.
</div>
) : (
<table>
<thead><tr><th>Nombre</th><th>Email</th><th>Casillero</th><th>Rol</th><th>Estado</th><th>Acciones</th></tr></thead>
<thead>
<tr>
<th>Nombre</th><th>Email</th><th>Casillero</th><th>Rol</th><th>Estado</th><th>Acciones</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
@@ -55,7 +105,11 @@ export default function UsuariosPage() {
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</td>
<td><span className={`badge ${u.isActive ? "badge-green" : "badge-red"}`}>{u.isActive ? "Activo" : "Inactivo"}</span></td>
<td>
<span className={`badge ${u.isActive ? "badge-green" : "badge-red"}`}>
{u.isActive ? "Activo" : "Inactivo"}
</span>
</td>
<td>
<button
className={`btn btn-sm ${u.isActive ? "btn-danger" : "btn-success"}`}
@@ -72,6 +126,60 @@ export default function UsuariosPage() {
)}
</div>
</div>
{/* Create user modal */}
{showModal && (
<div style={{
position: "fixed", inset: 0, background: "rgba(0,0,0,.45)", zIndex: 1000,
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem"
}} onClick={e => { if (e.target === e.currentTarget) setShowModal(false); }}>
<div className="card" style={{ width: "100%", maxWidth: 480, maxHeight: "90vh", overflowY: "auto" }}>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="font-semibold">Crear nuevo usuario</span>
<button className="btn btn-ghost btn-sm" onClick={() => setShowModal(false)}></button>
</div>
<div className="card-body">
{createError && <div className="alert alert-error mb-4">{createError}</div>}
<form onSubmit={handleCreate} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
<div className="form-group">
<label className="form-label">Nombre *</label>
<input className="form-input" required value={form.firstName} onChange={setF("firstName")} placeholder="Juan" />
</div>
<div className="form-group">
<label className="form-label">Apellido *</label>
<input className="form-input" required value={form.lastName} onChange={setF("lastName")} placeholder="Pérez" />
</div>
</div>
<div className="form-group">
<label className="form-label">Email *</label>
<input className="form-input" required type="email" value={form.email} onChange={setF("email")} placeholder="juan@ejemplo.com" />
</div>
<div className="form-group">
<label className="form-label">Contraseña inicial *</label>
<input className="form-input" required type="password" minLength={8} value={form.password} onChange={setF("password")} placeholder="Mínimo 8 caracteres" />
</div>
<div className="form-group">
<label className="form-label">Teléfono</label>
<input className="form-input" type="tel" value={form.phone} onChange={setF("phone")} placeholder="+593 99 000 0000" />
</div>
<div className="form-group">
<label className="form-label">Rol *</label>
<select className="form-input" value={form.role} onChange={setF("role")}>
{ROLES.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div style={{ display: "flex", gap: ".75rem", justifyContent: "flex-end", marginTop: ".5rem" }}>
<button type="button" className="btn btn-outline" onClick={() => setShowModal(false)}>Cancelar</button>
<button type="submit" className="btn btn-primary" disabled={creating}>
{creating ? "Creando…" : "Crear usuario"}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
);
}
+26 -8
View File
@@ -37,10 +37,12 @@ const STATUS_BADGE: Record<string, string> = {
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 [selected, setSelected] = useState<any | null>(null);
const [packages, setPackages] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [detail, setDetail] = useState<Record<string, any>>({});
const [loadingId, setLoadingId] = useState<string | null>(null);
useEffect(() => {
api.packages.list()
@@ -49,6 +51,20 @@ export default function MisPaquetesPage() {
.finally(() => setLoading(false));
}, []);
// Expand: fetch full package (includes complete statusHistory) on first open
const handleExpand = async (p: any) => {
if (selected === p.id) { setSelected(null); return; }
setSelected(p.id);
if (detail[p.id]) return; // already loaded
setLoadingId(p.id);
try {
const full = await api.packages.get(p.id);
setDetail(d => ({ ...d, [p.id]: full }));
} catch {
setDetail(d => ({ ...d, [p.id]: p })); // fallback to list data
} finally { setLoadingId(null); }
};
const filtered = packages.filter(p =>
!filter ||
p.trackingId?.toLowerCase().includes(filter.toLowerCase()) ||
@@ -87,7 +103,7 @@ export default function MisPaquetesPage() {
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{filtered.map(p => (
<div key={p.id} className="card" style={{ cursor: "pointer" }} onClick={() => setSelected(p === selected ? null : p)}>
<div key={p.id} className="card" style={{ cursor: "pointer" }} onClick={() => handleExpand(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>
@@ -122,12 +138,14 @@ export default function MisPaquetesPage() {
</div>
{/* Detalle expandido */}
{selected?.id === p.id && (
{selected === p.id && (
<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 ? (
{loadingId === p.id ? (
<div style={{ display: "flex", justifyContent: "center", padding: "1rem" }}><div className="spinner" /></div>
) : (detail[p.id]?.statusHistory ?? p.statusHistory)?.length ? (
<div className="timeline">
{p.statusHistory.map((h: any, i: number) => (
{(detail[p.id]?.statusHistory ?? p.statusHistory).map((h: any, i: number) => (
<div key={h.id} className="timeline-item">
<div className={`timeline-dot ${i === 0 ? "current" : "active"}`} />
<div>
+145 -53
View File
@@ -2,65 +2,96 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { api, getUser } from "@/lib/api";
import { Timestamp } from "@/app/_components/timestamp";
// §08 — 11 estados oficiales del ciclo de vida
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",
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",
RETENIDO_ADUANA: "Retenido en aduana",
DEVUELTO: "Devuelto",
PERDIDO: "Perdido",
CANCELADO: "Cancelado",
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",
};
const STATUS_COLOR: 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",
};
// §08 ordered pipeline for progress bar
const STATUS_ORDER = [
"REGISTRADO","EN_TRANSITO_BODEGA","RECIBIDO_BODEGA","EN_VERIFICACION",
"VERIFICADO","DECLARACION_ADUANERA","EN_TRANSITO_ECUADOR","EN_ADUANA_ECUADOR",
"LISTO_ENTREGA","ENTREGADO",
];
export default function PortalDashboard() {
const user = getUser();
const [packages, setPackages] = useState<any[]>([]);
const [packages, setPackages] = useState<any[]>([]);
const [notifications, setNotifications] = useState<any[]>([]);
const [suite, setSuite] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [suite, setSuite] = useState<any>(null);
const [preAlerts, setPreAlerts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
api.packages.list(),
api.auth.me(),
]).then(([pkgs, me]) => {
setPackages(pkgs.slice(0, 5));
setNotifications([]);
api.preAlerts.list().catch(() => []),
api.notifications.list(10).catch(() => []),
]).then(([pkgs, me, alerts, notifs]) => {
setPackages(pkgs);
setSuite(me.suite);
setPreAlerts(alerts);
setNotifications(notifs);
}).catch(() => {}).finally(() => setLoading(false));
}, []);
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
const active = packages.filter(p => !["ENTREGADO","CANCELADO","DEVUELTO"].includes(p.status)).length;
const active = packages.filter(p => !["ENTREGADO","INCIDENCIA"].includes(p.status)).length;
const delivered = packages.filter(p => p.status === "ENTREGADO").length;
const pending = preAlerts.filter(a => a.status === "PENDIENTE").length;
// Last 3 active packages
const recent = packages.slice(0, 5);
return (
<div>
<div className="mb-6">
<h1 className="dash-page-title">Bienvenido, {user?.firstName} 👋</h1>
<p className="dash-page-subtitle">Gestiona tus envíos y tu casillero en NJ.</p>
<p className="dash-page-subtitle">Gestiona tus envíos desde New Jersey hasta Ecuador.</p>
</div>
{/* Stats */}
{/* KPIs */}
<div className="grid-4" style={{ marginBottom: "2rem" }}>
{[
{ label: "Paquetes activos", value: active, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "Pre-alertas", value: "—", color: "var(--yellow)" },
{ label: "Mi casillero", value: suite?.code ?? "—", color: "var(--accent)" },
{ label: "Paquetes activos", value: active, color: "var(--primary)" },
{ label: "Entregados", value: delivered, color: "var(--green)" },
{ label: "Pre-alertas", value: pending, color: "var(--yellow)" },
{ label: "Mi casillero", value: suite?.code ?? "—", color: "var(--accent)" },
].map(s => (
<div key={s.label} className="stat-card">
<div className="stat-value" style={{ color: s.color }}>{s.value}</div>
@@ -69,29 +100,63 @@ export default function PortalDashboard() {
))}
</div>
<div className="grid-2" style={{ gap: "1.5rem" }}>
{/* Últimos paquetes */}
{/* Suite address callout */}
{suite && (
<div style={{ background: "var(--blue-50)", border: "1px solid var(--blue-200)", borderRadius: 10, padding: "1rem 1.25rem", marginBottom: "1.5rem", display: "flex", gap: "1rem", alignItems: "center", flexWrap: "wrap" }}>
<div style={{ fontSize: "1.5rem" }}>📦</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 700, fontSize: ".9rem", color: "var(--primary)", marginBottom: ".25rem" }}>Tu dirección de envío en NJ</div>
<div style={{ fontFamily: "monospace", fontSize: ".85rem", color: "var(--gray-700)" }}>
150 N Day St, <strong>{suite.code}</strong>, City of Orange, NJ 07050, EE.UU.
</div>
</div>
<Link href="/portal/mi-casillero" className="btn btn-outline btn-sm">Ver casillero </Link>
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
{/* Paquetes recientes con mini-barra de progreso */}
<div className="card">
<div className="card-header flex justify-between items-center">
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="font-semibold">Paquetes recientes</span>
<Link href="/portal/mis-paquetes" className="btn btn-ghost btn-sm text-primary">Ver todos </Link>
</div>
<div className="card-body" style={{ padding: 0 }}>
{packages.length === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", textAlign: "center" }}>Aún no tienes paquetes.</p>
{recent.length === 0 ? (
<div style={{ padding: "2rem", textAlign: "center" }}>
<div style={{ fontSize: "2rem", marginBottom: ".5rem" }}>📭</div>
<p style={{ color: "var(--gray-500)", fontSize: ".9rem" }}>Aún no tienes paquetes.</p>
<Link href="/portal/pre-alerta" className="btn btn-primary btn-sm" style={{ marginTop: ".75rem" }}>
Registrar primera compra
</Link>
</div>
) : (
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
<table>
<tbody>
{packages.map(p => (
<tr key={p.id}>
<td><Link href={`/portal/mis-paquetes?id=${p.id}`} style={{ color: "var(--primary)", fontWeight: 600 }}>{p.trackingId}</Link></td>
<td className="text-sm text-muted truncate" style={{ maxWidth: 120 }}>{p.description ?? "—"}</td>
<td><span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`}>{STATUS_LABEL[p.status] ?? p.status}</span></td>
</tr>
))}
</tbody>
</table>
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{recent.map((p, i) => {
const step = STATUS_ORDER.indexOf(p.status);
const pct = step >= 0 ? Math.round(((step + 1) / STATUS_ORDER.length) * 100) : 0;
return (
<div key={p.id} style={{ padding: "1rem 1.25rem", borderBottom: i < recent.length - 1 ? "1px solid var(--gray-100)" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: ".4rem" }}>
<div>
<Link href={`/portal/mis-paquetes?id=${p.id}`} style={{ color: "var(--primary)", fontWeight: 700, fontSize: ".9rem" }}>
{p.trackingId}
</Link>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)", marginTop: ".1rem", maxWidth: 200, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{p.description ?? "Sin descripción"}
</div>
</div>
<span className={`badge ${STATUS_BADGE[p.status] ?? "badge-gray"}`} style={{ fontSize: ".7rem" }}>
{STATUS_LABEL[p.status] ?? p.status}
</span>
</div>
{/* Progress bar */}
<div style={{ height: 4, background: "var(--gray-100)", borderRadius: 2, overflow: "hidden" }}>
<div style={{ height: "100%", width: `${pct}%`, background: STATUS_COLOR[p.status] ?? "var(--primary)", borderRadius: 2, transition: "width .3s" }} />
</div>
</div>
);
})}
</div>
)}
</div>
@@ -99,19 +164,30 @@ export default function PortalDashboard() {
{/* Notificaciones */}
<div className="card">
<div className="card-header flex justify-between items-center">
<span className="font-semibold">Notificaciones</span>
<span className="badge badge-red">{notifications.length}</span>
<div className="card-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span className="font-semibold">Notificaciones recientes</span>
{notifications.length > 0 && <span className="badge badge-red">{notifications.length}</span>}
</div>
<div className="card-body" style={{ padding: 0 }}>
{notifications.length === 0 ? (
<p style={{ padding: "1.5rem", color: "var(--gray-500)", textAlign: "center" }}>Sin notificaciones nuevas.</p>
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)", fontSize: ".9rem" }}>
Sin notificaciones nuevas.
</div>
) : (
<div style={{ display: "flex", flexDirection: "column" }}>
{notifications.map(n => (
<div key={n.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)" }}>
<div style={{ fontWeight: 600, fontSize: ".9rem" }}>{n.title}</div>
<div style={{ fontSize: ".8rem", color: "var(--gray-500)", marginTop: ".2rem" }}>{n.body}</div>
{notifications.map((n, i) => (
<div key={n.id} style={{ padding: ".875rem 1.25rem", borderBottom: i < notifications.length - 1 ? "1px solid var(--gray-100)" : "none" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: ".5rem" }}>
<div style={{ fontSize: ".82rem", color: "var(--gray-700)", lineHeight: 1.5, flex: 1 }}>
{n.subject || n.body?.slice(0, 100)}
</div>
<span style={{ fontSize: ".7rem", color: "var(--gray-400)", flexShrink: 0 }}>
<Timestamp value={n.createdAt} dateOnly />
</span>
</div>
<div style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".2rem" }}>
{n.channel === "EMAIL" ? "✉️" : n.channel === "WHATSAPP" ? "💬" : "🔔"} {n.channel}
</div>
</div>
))}
</div>
@@ -119,6 +195,22 @@ export default function PortalDashboard() {
</div>
</div>
</div>
{/* Accesos rápidos */}
<div style={{ marginTop: "1.5rem", display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", gap: "1rem" }}>
{[
{ href: "/portal/pre-alerta", icon: "📄", label: "Pre-alerta", desc: "Avisa qué paquete esperas" },
{ href: "/portal/calculadora", icon: "💰", label: "Calculadora", desc: "Estima el costo de envío" },
{ href: "/portal/consolidacion", icon: "📦", label: "Consolidar", desc: "Agrupar paquetes" },
{ href: "/portal/perfil", icon: "👤", label: "Mi perfil", desc: "Datos y seguridad" },
].map(a => (
<Link key={a.href} href={a.href} className="card" style={{ padding: "1.25rem", textDecoration: "none", display: "block", transition: "box-shadow .2s" }}>
<div style={{ fontSize: "1.75rem", marginBottom: ".5rem" }}>{a.icon}</div>
<div style={{ fontWeight: 700, fontSize: ".9rem", marginBottom: ".2rem" }}>{a.label}</div>
<div style={{ fontSize: ".78rem", color: "var(--gray-500)" }}>{a.desc}</div>
</Link>
))}
</div>
</div>
);
}
+20 -5
View File
@@ -63,16 +63,31 @@ export default function PreAlertaPage() {
e.preventDefault();
setSubmitting(true); setError(""); setSuccess("");
try {
await api.preAlerts.create({
const created = await api.preAlerts.create({
store: form.store,
vendorTracking: form.vendorTracking || undefined,
description: form.description,
declaredValue: parseFloat(form.declaredValue),
estimatedArrival: form.estimatedArrival || undefined,
});
setSuccess("✅ Pre-alerta registrada exitosamente.");
// Upload invoice file if the user selected one
if (invoice && created?.id) {
try {
await api.preAlerts.uploadInvoice(created.id, invoice);
} catch {
// Non-fatal: alert was created, just notify about the upload failure
setSuccess("✅ Pre-alerta registrada. No se pudo subir la factura, inténtalo de nuevo.");
setForm({ store: "", vendorTracking: "", description: "", declaredValue: "", estimatedArrival: "" });
setUrlInput(""); setInvoice(null);
if (invoiceRef.current) invoiceRef.current.value = "";
load();
return;
}
}
setSuccess("✅ Pre-alerta registrada exitosamente." + (invoice ? " Factura adjuntada." : ""));
setForm({ store: "", vendorTracking: "", description: "", declaredValue: "", estimatedArrival: "" });
setUrlInput(""); setInvoice(null);
if (invoiceRef.current) invoiceRef.current.value = "";
load();
} catch (err: any) { setError(err.message ?? "Error al registrar"); }
finally { setSubmitting(false); }
@@ -168,10 +183,10 @@ export default function PreAlertaPage() {
onChange={e => setInvoice(e.target.files?.[0] ?? null)}
style={{ fontSize: ".85rem" }} />
{invoice && (
<p style={{ fontSize: ".78rem", color: "var(--green)", marginTop: ".25rem" }}>📎 {invoice.name}</p>
)}
<p style={{ fontSize: ".78rem", color: "var(--green)", marginTop: ".25rem" }}>📎 {invoice.name}</p>
)}
<p style={{ fontSize: ".75rem", color: "var(--gray-400)", marginTop: ".25rem" }}>
Próximamente: la factura se enviará automáticamente a la bodega.
Máx. 10 MB PDF, JPG, PNG o WEBP.
</p>
</div>
<button type="submit" className="btn btn-primary" disabled={submitting}>
+15
View File
@@ -116,10 +116,22 @@ export const api = {
create: (body: any) => request<any>("/pre-alerts", { method: "POST", body: JSON.stringify(body) }),
delete: (id: string) => request<any>(`/pre-alerts/${id}`, { method: "DELETE" }),
updateStatus: (id: string, body: any) => request<any>(`/pre-alerts/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
uploadInvoice: (id: string, file: File) => {
const token = getToken();
const fd = new FormData();
fd.append("invoice", file);
return fetch(`${API_BASE}/pre-alerts/${id}/invoice`, {
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
}).then(r => { if (!r.ok) throw new Error("Upload failed"); return r.json(); });
},
},
users: {
list: (search?: string) => request<any[]>("/users" + (search ? `?search=${search}` : "")),
get: (id: string) => request<any>(`/users/${id}`),
create: (body: { email: string; password: string; firstName: string; lastName: string; phone?: string; role: string }) =>
request<any>("/users", { method: "POST", body: JSON.stringify(body) }),
updateRole: (id: string, role: string) => request<any>(`/users/${id}/role`, { method: "PATCH", body: JSON.stringify({ role }) }),
setActive: (id: string, isActive: boolean) => request<any>(`/users/${id}/active`, { method: "PATCH", body: JSON.stringify({ isActive }) }),
},
@@ -165,6 +177,9 @@ export const api = {
update: (id: string, body: { body: string; subject?: string; isActive?: boolean }) =>
request<any>(`/notification-templates/${id}`, { method: "PUT", body: JSON.stringify(body) }),
},
notifications: {
list: (limit = 20) => request<any[]>(`/notifications?limit=${limit}`),
},
payments: {
list: (status?: string) => request<any[]>(`/payments${status ? `?status=${status}` : ""}`),
packageDetail: (packageId: string) => request<any>(`/payments/package/${packageId}`),