feat: Fase 2 — SENAE DSI, verificación bodega, fotos, notificaciones, auditoría, tarifas CRUD, páginas faltantes
API:
- PackagesService: verifyPackage (discrepancia >10%), addPhotos (multer disk), generateSenaeDeclaration (DSI stub)
- PATCH /packages/:id/verify — peso real + dimensiones + detección automática de discrepancia
- POST /packages/:id/photos — upload multipart fotos (hasta 10, 10 MB c/u)
- POST /packages/:id/senae/declare — genera DSI, N° autorización SENAE, cambia estado DECLARACION_ADUANERA
- GET /packages/pending-declaration — cola de paquetes VERIFICADO para agente aduanero
- AuditLogModule: GET /audit-logs con filtros (acción, recurso, fechas, paginación)
- NotificationsModule: notifyStatusChange → crea registros EMAIL/WHATSAPP/PUSH en DB (stub dispatch)
- TariffsModule: GET/PUT /tariffs — configuración de tarifas por tenant (pricePerLb, IVA, FODINFA…)
- ProductsModule: POST /products/scan — stub extracción de producto desde URL Amazon/eBay/Walmart
- StorageModule: saveFile/deleteFile con disco local (S3-ready)
- main.ts: NestExpressApplication + useStaticAssets('/uploads')
- Dirección bodega: de hardcoded a env vars (WAREHOUSE_ADDRESS_*)
Web:
- /bodega/verificacion — formulario real: peso real, dims, fotos, detección discrepancia, notificación
- /bodega/despacho — lista paquetes DECLARACION_ADUANERA → despacha a EN_TRANSITO_ECUADOR
- /bodega/declaraciones — cola SENAE: auto-detecta categoría 4×4, genera DSI por paquete
- /bodega/layout — agrega enlace Declaraciones SENAE, corrige ALLOWED roles
- /admin/tarifas — CRUD real: edita pricePerLb, IVA, FODINFA, límites 4×4; preview fórmulas
- /admin/auditoria — tabla paginada con filtros desde GET /audit-logs
- /portal/pre-alerta — tabs URL-scan / Manual, upload de factura, campos correctos
- /casillero/como-usar — timeline 9 pasos con dirección NJ y CTA
- /carga-pesada/como-funciona — flujo B2B 5 pasos, servicios incluidos
- /carga-pesada/inen — 6 categorías reguladas, callout de advertencia, proceso de asistencia
- api.ts: verify, uploadPhotos, senaeDeclare, pendingDeclaration, tariffs, auditLogs, products.scan
This commit is contained in:
@@ -1,20 +1,130 @@
|
||||
"use client";
|
||||
// Auditoría — placeholder con nota de implementación futura (requiere endpoint /audit)
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export default function AuditoriaPage() {
|
||||
const [logs, setLogs] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [filters, setFilters] = useState({ action: "", resource: "", from: "", to: "" });
|
||||
const LIMIT = 50;
|
||||
|
||||
const load = async (p = 1) => {
|
||||
setLoading(true);
|
||||
const params: Record<string, string> = { page: String(p), limit: String(LIMIT) };
|
||||
if (filters.action) params.action = filters.action;
|
||||
if (filters.resource) params.resource = filters.resource;
|
||||
if (filters.from) params.from = filters.from;
|
||||
if (filters.to) params.to = filters.to;
|
||||
try {
|
||||
const res = await api.auditLogs.list(params);
|
||||
setLogs(res.data);
|
||||
setTotal(res.total);
|
||||
} catch {}
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(page); }, [page]);
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setFilters(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
load(1);
|
||||
};
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
LOGIN_SUCCESS: "badge-green", LOGIN_FAILED: "badge-red", LOGOUT: "badge-gray",
|
||||
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 (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="dash-page-title">Auditoría</h1><p className="dash-page-subtitle">Registro de acciones del sistema (AuditLog).</p></div>
|
||||
<div className="card">
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Log de Auditoría</h1>
|
||||
<p className="dash-page-subtitle">Registro inmutable de todas las acciones del sistema — ISO 27001 A.12. {total} eventos.</p>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card mb-4">
|
||||
<div className="card-body">
|
||||
<div className="alert alert-info">
|
||||
El log de auditoría se registra automáticamente en la tabla <code>AuditLog</code> de la base de datos.
|
||||
Para consultarlo directamente, accede al panel de base de datos o agrega el endpoint <code>GET /api/audit</code> en la API.
|
||||
</div>
|
||||
<p style={{ marginTop: "1rem", fontSize: ".9rem", color: "var(--gray-500)" }}>
|
||||
Acciones registradas: LOGIN_SUCCESS, LOGIN_FAILED, LOGOUT, MFA_ENABLED, USER_REGISTER, y más.
|
||||
</p>
|
||||
<form onSubmit={handleSearch} style={{ display: "flex", gap: "1rem", flexWrap: "wrap", alignItems: "flex-end" }}>
|
||||
<div className="form-group" style={{ flex: "1 1 140px" }}>
|
||||
<label className="form-label">Acción</label>
|
||||
<input className="form-input" placeholder="LOGIN_SUCCESS…" value={filters.action} onChange={set("action")} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: "1 1 120px" }}>
|
||||
<label className="form-label">Recurso</label>
|
||||
<input className="form-input" placeholder="User, Package…" value={filters.resource} onChange={set("resource")} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: "1 1 140px" }}>
|
||||
<label className="form-label">Desde</label>
|
||||
<input className="form-input" type="date" value={filters.from} onChange={set("from")} />
|
||||
</div>
|
||||
<div className="form-group" style={{ flex: "1 1 140px" }}>
|
||||
<label className="form-label">Hasta</label>
|
||||
<input className="form-input" type="date" value={filters.to} onChange={set("to")} />
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" style={{ height: "38px" }}>Filtrar</button>
|
||||
<button type="button" className="btn btn-ghost" style={{ height: "38px" }}
|
||||
onClick={() => { setFilters({ action: "", resource: "", from: "", to: "" }); setPage(1); load(1); }}>
|
||||
Limpiar
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><div className="spinner" /></div>
|
||||
) : (
|
||||
<div className="table-wrap" style={{ border: "none" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fecha (ECT)</th>
|
||||
<th>Acción</th>
|
||||
<th>Recurso</th>
|
||||
<th>ID Recurso</th>
|
||||
<th>Usuario ID</th>
|
||||
<th>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<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><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>
|
||||
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem" }}>{l.userId?.slice(0,12) ?? "—"}</td>
|
||||
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem" }}>{l.ipAddress ?? "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pages > 1 && (
|
||||
<div style={{ display: "flex", gap: ".5rem", padding: "1rem", justifyContent: "center" }}>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page === 1} onClick={() => setPage(p => p - 1)}>← Anterior</button>
|
||||
<span style={{ padding: "6px 12px", fontSize: ".85rem", color: "var(--gray-600)" }}>
|
||||
Página {page} de {pages}
|
||||
</span>
|
||||
<button className="btn btn-ghost btn-sm" disabled={page === pages} onClick={() => setPage(p => p + 1)}>Siguiente →</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,138 @@
|
||||
"use client";
|
||||
|
||||
// Página de tarifas — muestra tabla estática de precios (§05 doc) + formulario de edición futuro
|
||||
const TARIFAS = [
|
||||
{ category: "Mensajería Acelerada", max: "$200", exento: "Sí (≤ $200)", flete: "$8–$15/lb", notas: "Hasta 4 kg · sin impuestos" },
|
||||
{ category: "Courier", max: "$400", exento: "≤ $200", flete: "$8–$15/lb", notas: "Impuestos desde $200.01" },
|
||||
{ category: "Régimen 4×4", max: "$2,000",exento: "No", flete: "Variable", notas: "SENAE arancel + IVA + FODINFA" },
|
||||
{ category: "Carga pesada (FCL)", max: "Ilimitado", exento: "No", flete: "Cotización", notas: "Tarifa por m³ y peso" },
|
||||
];
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
export default function TarifasPage() {
|
||||
const [tariff, setTariff] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
pricePerLb: "3.50",
|
||||
insurancePct: "0.02",
|
||||
fodinfaPct: "0.005",
|
||||
ivaPct: "0.15",
|
||||
max4x4Value: "400",
|
||||
max4x4WeightKg: "4",
|
||||
max4x4PerYear: "4",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
api.tariffs.get()
|
||||
.then((t) => {
|
||||
setTariff(t);
|
||||
setForm({
|
||||
pricePerLb: String(t.pricePerLb),
|
||||
insurancePct: String(t.insurancePct),
|
||||
fodinfaPct: String(t.fodinfaPct),
|
||||
ivaPct: String(t.ivaPct),
|
||||
max4x4Value: String(t.max4x4Value),
|
||||
max4x4WeightKg: String(t.max4x4WeightKg),
|
||||
max4x4PerYear: String(t.max4x4PerYear),
|
||||
});
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true); setMsg(null);
|
||||
try {
|
||||
const updated = await api.tariffs.update({
|
||||
pricePerLb: parseFloat(form.pricePerLb),
|
||||
insurancePct: parseFloat(form.insurancePct),
|
||||
fodinfaPct: parseFloat(form.fodinfaPct),
|
||||
ivaPct: parseFloat(form.ivaPct),
|
||||
max4x4Value: parseFloat(form.max4x4Value),
|
||||
max4x4WeightKg: parseFloat(form.max4x4WeightKg),
|
||||
max4x4PerYear: parseInt(form.max4x4PerYear),
|
||||
});
|
||||
setTariff(updated);
|
||||
setMsg({ type: "success", text: "Tarifas actualizadas correctamente." });
|
||||
} catch (err: any) {
|
||||
setMsg({ type: "error", text: err.message ?? "Error al guardar" });
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const FIELDS = [
|
||||
{ key: "pricePerLb", label: "Precio por libra (USD)", step: "0.01", min: "0.1", suffix: "$/lb" },
|
||||
{ key: "insurancePct", label: "Seguro (% sobre valor declarado)", step: "0.001", min: "0", suffix: "%" },
|
||||
{ key: "fodinfaPct", label: "FODINFA (% — fijo SENAE 0.5%)", step: "0.001", min: "0", suffix: "%" },
|
||||
{ key: "ivaPct", label: "IVA Ecuador (%)", step: "0.01", min: "0", suffix: "%" },
|
||||
{ key: "max4x4Value", label: "Límite 4×4: valor máximo (USD)", step: "1", min: "1", suffix: "$" },
|
||||
{ key: "max4x4WeightKg", label: "Límite 4×4: peso máximo (kg)", step: "0.1", min: "0.1",suffix: "kg" },
|
||||
{ key: "max4x4PerYear", label: "Límite 4×4: envíos máx./año", step: "1", min: "1", suffix: "envíos" },
|
||||
];
|
||||
|
||||
if (loading) return <div className="flex justify-center py-16"><div className="spinner" /></div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Tarifas</h1>
|
||||
<p className="dash-page-subtitle">Tabla de regímenes aduaneros y precios base.</p>
|
||||
<h1 className="dash-page-title">Configuración de Tarifas</h1>
|
||||
<p className="dash-page-subtitle">Ajusta precios de flete, seguros, impuestos SENAE y límites del régimen 4×4 (doc §15).</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: "1.5rem" }}>
|
||||
<div className="card-header"><span className="font-semibold">Regímenes aduaneros (§05 / §15)</span></div>
|
||||
<div className="table-wrap" style={{ border: "none", borderRadius: 0 }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Régimen</th><th>Valor máx.</th><th>Exento</th><th>Flete</th><th>Notas</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TARIFAS.map(t => (
|
||||
<tr key={t.category}>
|
||||
<td className="font-semibold">{t.category}</td>
|
||||
<td>{t.max}</td>
|
||||
<td><span className={`badge ${t.exento.startsWith("Sí") || t.exento.startsWith("≤") ? "badge-green" : "badge-gray"}`}>{t.exento}</span></td>
|
||||
<td>{t.flete}</td>
|
||||
<td className="text-sm text-muted">{t.notas}</td>
|
||||
</tr>
|
||||
{msg && <div className={`alert alert-${msg.type} mb-4`}>{msg.text}</div>}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
|
||||
{/* Edit form */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Editar tarifas</span></div>
|
||||
<div className="card-body">
|
||||
<form onSubmit={handleSave} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{FIELDS.map(f => (
|
||||
<div key={f.key} className="form-group">
|
||||
<label className="form-label">{f.label}</label>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: ".5rem" }}>
|
||||
<input className="form-input" type="number" step={f.step} min={f.min}
|
||||
value={form[f.key as keyof typeof form]} onChange={set(f.key)} required style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: ".85rem", color: "var(--gray-500)", minWidth: "40px" }}>{f.suffix}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="submit" className="btn btn-primary" disabled={saving} style={{ marginTop: ".5rem" }}>
|
||||
{saving ? "Guardando…" : "Guardar cambios"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="alert alert-info">
|
||||
<strong>Nota:</strong> Las tarifas de flete varían según el peso volumétrico (L×W×H / 139) vs. peso real. Se cobra el mayor. Los impuestos SENAE (FODINFA 0.5%, Arancel variable, IVA 15%) se calculan automáticamente en la calculadora del portal.
|
||||
{/* Preview */}
|
||||
<div>
|
||||
<div className="card" style={{ marginBottom: "1rem" }}>
|
||||
<div className="card-header"><span className="font-semibold">Tarifas actuales</span></div>
|
||||
<div className="table-wrap" style={{ border: "none" }}>
|
||||
<table>
|
||||
<thead><tr><th>Concepto</th><th>Valor</th></tr></thead>
|
||||
<tbody>
|
||||
{tariff && FIELDS.map(f => (
|
||||
<tr key={f.key}>
|
||||
<td className="text-sm">{f.label}</td>
|
||||
<td className="font-semibold">{tariff[f.key]} {f.suffix}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formula reminder */}
|
||||
<div className="card" style={{ background: "var(--dark)", color: "#A5B4FC" }}>
|
||||
<div className="card-body" style={{ fontFamily: "monospace", fontSize: ".78rem", lineHeight: 1.9 }}>
|
||||
<div style={{ color: "rgba(255,255,255,.3)", marginBottom: ".5rem" }}>// Fórmulas SENAE (doc §15)</div>
|
||||
<div>Flete = Peso_final × <span style={{ color: "#FCD34D" }}>{form.pricePerLb}</span></div>
|
||||
<div>Seguro = Valor × <span style={{ color: "#FCD34D" }}>{form.insurancePct}</span></div>
|
||||
<div>FODINFA = Valor × <span style={{ color: "#FCD34D" }}>{form.fodinfaPct}</span></div>
|
||||
<div>IVA = (Valor + FODINFA + Arancel) × <span style={{ color: "#FCD34D" }}>{form.ivaPct}</span></div>
|
||||
<div style={{ color: "#6EE7B7", marginTop: ".5rem", fontWeight: 700 }}>TOTAL = Flete + Seguro + FODINFA + Arancel + IVA</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// SENAE Declaraciones — doc §11: Agente Aduanero genera DSI para paquetes VERIFICADOS
|
||||
export default function DeclaracionesPage() {
|
||||
const [packages, setPackages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const [form, setForm] = useState({ category: "REGIMEN_4X4", agentNotes: "" });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const load = () => {
|
||||
api.packages.pendingDeclaration()
|
||||
.then(setPackages).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const autoCategory = (pkg: any): string => {
|
||||
const value = parseFloat(pkg.declaredValue ?? 0);
|
||||
const weightKg = (parseFloat(pkg.actualWeight ?? pkg.declaredWeight ?? 0)) * 0.453592;
|
||||
if (value <= 400 && weightKg <= 4) return "REGIMEN_4X4";
|
||||
return "CATEGORIA_B";
|
||||
};
|
||||
|
||||
const handleSelect = (pkg: any) => {
|
||||
setSelected(pkg);
|
||||
setForm({ category: autoCategory(pkg), agentNotes: "" });
|
||||
setMsg(null);
|
||||
};
|
||||
|
||||
const handleDeclare = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
setSubmitting(true); setMsg(null);
|
||||
try {
|
||||
const result = await api.packages.senaeDeclare(selected.id, form);
|
||||
setMsg({ type: "success", text: `✅ DSI generada. N° Autorización SENAE: ${result.authNumber}` });
|
||||
setSelected(null);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
setMsg({ type: "error", text: err.message ?? "Error al generar declaración" });
|
||||
} finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "REGIMEN_4X4", label: "Régimen 4×4 — 0% (≤$400, ≤4kg)" },
|
||||
{ value: "CATEGORIA_B", label: "Categoría B — 10% (bienes generales)" },
|
||||
{ value: "CATEGORIA_C", label: "Categoría C — 20% (textiles, calzado, hogar)" },
|
||||
{ value: "CATEGORIA_D", label: "Categoría D — 0–15% (electrónicos)" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Declaraciones SENAE</h1>
|
||||
<p className="dash-page-subtitle">Cola de paquetes verificados pendientes de DSI. Genera la Declaración Simplificada de Importación.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`alert alert-${msg.type} mb-4`}>{msg.text}</div>}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
|
||||
{/* Cola de paquetes VERIFICADOS */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="font-semibold">Pendientes de declaración</span>
|
||||
<span className="badge badge-orange ml-2">{packages.length}</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><div className="spinner" /></div>
|
||||
) : packages.length === 0 ? (
|
||||
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)" }}>
|
||||
No hay paquetes pendientes de declaración.<br/>
|
||||
<span style={{ fontSize: ".8rem" }}>Los paquetes en estado VERIFICADO aparecerán aquí.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap" style={{ border: "none" }}>
|
||||
<table>
|
||||
<thead><tr><th>Tracking</th><th>Valor</th><th>Peso real</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{packages.map(p => (
|
||||
<tr key={p.id} style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50)" : undefined }}
|
||||
onClick={() => handleSelect(p)}>
|
||||
<td className="font-semibold" style={{ color: "var(--blue)", fontSize: ".82rem" }}>{p.trackingId}</td>
|
||||
<td className="text-sm">${p.declaredValue}</td>
|
||||
<td className="text-sm">{p.actualWeight ? `${p.actualWeight}lb` : "—"}</td>
|
||||
<td>
|
||||
{parseFloat(p.declaredValue ?? 0) > 400 && (
|
||||
<span className="badge badge-red" style={{ fontSize: ".7rem" }}>Supera 4×4</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulario DSI */}
|
||||
{selected && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="font-semibold">Generar DSI: {selected.trackingId}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{/* Resumen del paquete */}
|
||||
<div style={{ background: "var(--gray-50)", borderRadius: "8px", padding: "12px", marginBottom: "1rem", fontSize: ".85rem" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".5rem" }}>
|
||||
<div><strong>Descripción:</strong> {selected.description}</div>
|
||||
<div><strong>Tienda:</strong> {selected.store ?? "—"}</div>
|
||||
<div><strong>Valor declarado:</strong> <strong style={{ color: parseFloat(selected.declaredValue) > 400 ? "var(--red)" : "var(--green)" }}>${selected.declaredValue}</strong></div>
|
||||
<div><strong>Peso real:</strong> {selected.actualWeight ? `${selected.actualWeight}lb` : selected.declaredWeight ? `${selected.declaredWeight}lb (decl.)` : "—"}</div>
|
||||
<div><strong>Cliente:</strong> {selected.user?.firstName} {selected.user?.lastName}</div>
|
||||
</div>
|
||||
{parseFloat(selected.declaredValue ?? 0) > 400 && (
|
||||
<div style={{ marginTop: ".75rem", padding: "8px 12px", background: "#FFF3E8", borderRadius: "6px", borderLeft: "3px solid var(--orange)", fontSize: ".8rem" }}>
|
||||
⚠️ <strong>Supera el límite 4×4 ($400).</strong> Se requiere proceso de importación formal (DAI).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleDeclare} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Categoría SENAE *</label>
|
||||
<select className="form-input" value={form.category} onChange={set("category")}>
|
||||
{CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Notas del agente</label>
|
||||
<textarea className="form-input" rows={3} value={form.agentNotes} onChange={set("agentNotes")}
|
||||
placeholder="Observaciones o partida arancelaria..." />
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: ".75rem" }}>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting} style={{ flex: 1 }}>
|
||||
{submitting ? "Generando DSI…" : "🛃 Generar y enviar DSI a SENAE"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setSelected(null)}>Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Despacho — paquetes listos para enviar a Ecuador
|
||||
// Despacho — doc §10 step 5/6: paquetes DECLARACION_ADUANERA → EN_TRANSITO_ECUADOR
|
||||
export default function DespachoPage() {
|
||||
const [packages, setPackages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -10,56 +10,84 @@ export default function DespachoPage() {
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
const load = () => {
|
||||
api.packages.list({ status: "LISTO_PARA_RETIRO" }).then(setPackages).catch(()=>{}).finally(()=>setLoading(false));
|
||||
api.packages.list({ status: "DECLARACION_ADUANERA" })
|
||||
.then(setPackages).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const markDispatched = async (id: string) => {
|
||||
const dispatch = async (id: string, trackingId: string) => {
|
||||
if (!confirm(`¿Despachar el paquete ${trackingId} hacia Ecuador?`)) return;
|
||||
setUpdating(id);
|
||||
try {
|
||||
await api.packages.updateStatus(id, { status: "EN_CAMINO_A_ECUADOR", notes: "Despachado desde bodega NJ" });
|
||||
setMsg("Paquete marcado como despachado.");
|
||||
await api.packages.updateStatus(id, {
|
||||
status: "EN_TRANSITO_ECUADOR",
|
||||
note: "Paquete despachado desde bodega NJ hacia Ecuador vía courier internacional.",
|
||||
});
|
||||
setMsg(`✅ Paquete ${trackingId} despachado correctamente.`);
|
||||
load();
|
||||
} catch (err: any) { setMsg(err.message ?? "Error"); }
|
||||
finally { setUpdating(null); }
|
||||
} catch (err: any) {
|
||||
setMsg(err.message ?? "Error al despachar");
|
||||
} finally { setUpdating(null); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="dash-page-title">Despacho</h1><p className="dash-page-subtitle">Paquetes listos para envío a Ecuador.</p></div>
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Despacho hacia Ecuador</h1>
|
||||
<p className="dash-page-subtitle">Paquetes con declaración SENAE aprobada listos para despacho. Estado → EN_TRANSITO_ECUADOR.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className="alert alert-success mb-4">{msg}</div>}
|
||||
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
|
||||
packages.length === 0 ? (
|
||||
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>✅</div>
|
||||
<p style={{ color: "var(--gray-500)" }}>No hay paquetes pendientes de despacho.</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><div className="spinner" /></div>
|
||||
) : packages.length === 0 ? (
|
||||
<div className="card" style={{ padding: "3rem", textAlign: "center" }}>
|
||||
<div style={{ fontSize: "3rem", marginBottom: "1rem" }}>✅</div>
|
||||
<p style={{ color: "var(--gray-500)" }}>No hay paquetes pendientes de despacho.</p>
|
||||
<p style={{ fontSize: ".85rem", color: "var(--gray-400)", marginTop: ".5rem" }}>
|
||||
Los paquetes en estado DECLARACION_ADUANERA aparecerán aquí.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tracking ID</th>
|
||||
<th>Cliente</th>
|
||||
<th>Descripción</th>
|
||||
<th>Peso real</th>
|
||||
<th>Valor decl.</th>
|
||||
<th>SENAE Auth</th>
|
||||
<th>Acción</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{packages.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td className="font-semibold" style={{ color: "var(--blue)", fontSize: ".85rem" }}>{p.trackingId}</td>
|
||||
<td className="text-sm">{p.user?.firstName} {p.user?.lastName}</td>
|
||||
<td className="text-sm">{p.description}</td>
|
||||
<td className="text-sm">{p.actualWeight ? `${p.actualWeight}lb` : p.declaredWeight ? `${p.declaredWeight}lb (decl.)` : "—"}</td>
|
||||
<td className="text-sm">${p.declaredValue}</td>
|
||||
<td className="text-sm" style={{ fontFamily: "monospace", fontSize: ".75rem", color: "var(--green)" }}>
|
||||
{p.senaeAuthNumber ?? "—"}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-primary btn-sm"
|
||||
disabled={updating === p.id}
|
||||
onClick={() => dispatch(p.id, p.trackingId)}>
|
||||
{updating === p.id ? "…" : "🚢 Despachar"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
|
||||
<table>
|
||||
<thead><tr><th>Tracking</th><th>Cliente</th><th>Peso</th><th>Valor declarado</th><th>Categoría</th><th>Acción</th></tr></thead>
|
||||
<tbody>
|
||||
{packages.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td className="font-semibold text-primary">{p.trackingId}</td>
|
||||
<td>{p.suite?.user?.firstName ?? "—"} {p.suite?.user?.lastName ?? ""}</td>
|
||||
<td>{p.weightLb ? `${p.weightLb} lb` : "—"}</td>
|
||||
<td>{p.declaredValueUsd ? `$${p.declaredValueUsd}` : "—"}</td>
|
||||
<td><span className="badge badge-blue">{(p.senaeCategory ?? "").replace(/_/g," ")}</span></td>
|
||||
<td>
|
||||
<button className="btn btn-primary btn-sm" disabled={updating === p.id}
|
||||
onClick={() => markDispatched(p.id)}>
|
||||
{updating === p.id ? "…" : "Despachar →"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,15 @@ import { getUser, clearAuth } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/bodega", icon: "◈", label: "Dashboard" },
|
||||
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
|
||||
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación NJ" },
|
||||
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
|
||||
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
|
||||
{ href: "/bodega", icon: "◈", label: "Dashboard" },
|
||||
{ href: "/bodega/paquetes", icon: "📦", label: "Paquetes" },
|
||||
{ href: "/bodega/verificacion", icon: "✅", label: "Verificación" },
|
||||
{ href: "/bodega/declaraciones", icon: "🛃", label: "Declaraciones SENAE" },
|
||||
{ href: "/bodega/despacho", icon: "🚢", label: "Despacho" },
|
||||
{ href: "/admin", icon: "⚙️", label: "→ Admin" },
|
||||
];
|
||||
|
||||
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANAS","ADMIN","SUPERADMIN"];
|
||||
const ALLOWED = ["OPERADOR_BODEGA","AGENTE_ADUANERO","ADMIN_EMPRESA","SUPER_ADMIN"];
|
||||
|
||||
export default function BodegaLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -1,73 +1,164 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Verificación NJ — muestra pre-alertas pendientes para vincular con paquetes recibidos
|
||||
// Verificación NJ — doc §10 step 4: pesar, medir, fotografiar paquetes
|
||||
export default function VerificacionPage() {
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [packages, setPackages] = useState<any[]>([]);
|
||||
const [selected, setSelected] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [msg, setMsg] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState<{ type: "success" | "error" | "warn"; text: string } | null>(null);
|
||||
const [form, setForm] = useState({ actualWeightLb: "", lengthCm: "", widthCm: "", heightCm: "", note: "" });
|
||||
const [photos, setPhotos] = useState<FileList | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => { api.preAlerts.list().then(setAlerts).catch(()=>{}).finally(()=>setLoading(false)); };
|
||||
const load = () => {
|
||||
api.packages.list({ status: "RECIBIDO_BODEGA" })
|
||||
.then(setPackages).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const handleStatus = async (id: string, status: string) => {
|
||||
setUpdating(id);
|
||||
try { await api.preAlerts.updateStatus(id, { status }); setMsg(`Pre-alerta marcada como ${status}.`); load(); }
|
||||
catch (err: any) { setMsg(err.message ?? "Error"); }
|
||||
finally { setUpdating(null); }
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const handleSelect = (pkg: any) => {
|
||||
setSelected(pkg);
|
||||
setForm({ actualWeightLb: pkg.declaredWeight ?? "", lengthCm: pkg.lengthCm ?? "", widthCm: pkg.widthCm ?? "", heightCm: pkg.heightCm ?? "", note: "" });
|
||||
setPhotos(null);
|
||||
setMsg(null);
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
setSubmitting(true); setMsg(null);
|
||||
try {
|
||||
const result = await api.packages.verify(selected.id, {
|
||||
actualWeightLb: parseFloat(form.actualWeightLb),
|
||||
lengthCm: form.lengthCm ? parseFloat(form.lengthCm) : undefined,
|
||||
widthCm: form.widthCm ? parseFloat(form.widthCm) : undefined,
|
||||
heightCm: form.heightCm ? parseFloat(form.heightCm) : undefined,
|
||||
note: form.note || undefined,
|
||||
});
|
||||
|
||||
// Upload photos if any
|
||||
if (photos && photos.length > 0) {
|
||||
const fd = new FormData();
|
||||
Array.from(photos).forEach(f => fd.append("photos", f));
|
||||
await api.packages.uploadPhotos(selected.id, fd);
|
||||
}
|
||||
|
||||
if (result.hasDiscrepancy) {
|
||||
setMsg({ type: "warn", text: `⚠️ DISCREPANCIA detectada: peso declarado ${selected.declaredWeight}lb vs real ${form.actualWeightLb}lb. El cliente y Admin fueron notificados.` });
|
||||
} else {
|
||||
setMsg({ type: "success", text: `✅ Paquete ${selected.trackingId} verificado correctamente.` });
|
||||
}
|
||||
setSelected(null);
|
||||
load();
|
||||
} catch (err: any) {
|
||||
setMsg({ type: "error", text: err.message ?? "Error al verificar" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6"><h1 className="dash-page-title">Verificación NJ</h1><p className="dash-page-subtitle">Vincula las pre-alertas de clientes con paquetes recibidos.</p></div>
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Verificación de Paquetes</h1>
|
||||
<p className="dash-page-subtitle">Confirma peso real, dimensiones y sube fotos. Detecta discrepancias >10% automáticamente.</p>
|
||||
</div>
|
||||
|
||||
{msg && <div className="alert alert-success mb-4">{msg}</div>}
|
||||
|
||||
{loading ? <div className="flex justify-center py-16"><div className="spinner" /></div> : (
|
||||
<div className="card">
|
||||
<div className="table-wrap" style={{ border: "none", borderRadius: "var(--radius-lg)" }}>
|
||||
<table>
|
||||
<thead><tr><th>Tienda</th><th>Orden</th><th>Descripción</th><th>Valor est.</th><th>Tracking proveedor</th><th>Estado</th><th>Acciones</th></tr></thead>
|
||||
<tbody>
|
||||
{alerts.length === 0 ? (
|
||||
<tr><td colSpan={7} style={{ textAlign: "center", padding: "2rem", color: "var(--gray-500)" }}>Sin pre-alertas.</td></tr>
|
||||
) : alerts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td className="font-semibold">{a.store}</td>
|
||||
<td className="text-sm">{a.orderNumber}</td>
|
||||
<td className="text-sm">{a.description ?? "—"}</td>
|
||||
<td className="text-sm">{a.estimatedValueUsd ? `$${a.estimatedValueUsd}` : "—"}</td>
|
||||
<td className="text-sm">{a.trackingNumber ?? "—"}</td>
|
||||
<td><span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span></td>
|
||||
<td>
|
||||
<div style={{ display: "flex", gap: ".5rem" }}>
|
||||
{a.status === "PENDIENTE" && (
|
||||
<>
|
||||
<button className="btn btn-success btn-sm" disabled={updating === a.id}
|
||||
onClick={() => handleStatus(a.id, "RECIBIDO")}>Recibido</button>
|
||||
<button className="btn btn-danger btn-sm" disabled={updating === a.id}
|
||||
onClick={() => handleStatus(a.id, "RECHAZADO")}>Rechazar</button>
|
||||
</>
|
||||
)}
|
||||
{a.status === "RECIBIDO" && (
|
||||
<button className="btn btn-primary btn-sm" disabled={updating === a.id}
|
||||
onClick={() => handleStatus(a.id, "VINCULADO")}>Vincular</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{msg && (
|
||||
<div className={`alert alert-${msg.type === "warn" ? "warning" : msg.type} mb-4`}>{msg.text}</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
|
||||
{/* Lista paquetes en RECIBIDO_BODEGA */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Paquetes pendientes de verificar</span></div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><div className="spinner" /></div>
|
||||
) : packages.length === 0 ? (
|
||||
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)" }}>
|
||||
No hay paquetes en estado RECIBIDO_BODEGA.
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-wrap" style={{ border: "none" }}>
|
||||
<table>
|
||||
<thead><tr><th>Tracking</th><th>Cliente</th><th>Peso decl.</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{packages.map(p => (
|
||||
<tr key={p.id} style={{ cursor: "pointer", background: selected?.id === p.id ? "var(--blue-50)" : undefined }}
|
||||
onClick={() => handleSelect(p)}>
|
||||
<td className="font-semibold text-primary" style={{ fontSize: ".8rem" }}>{p.trackingId}</td>
|
||||
<td className="text-sm">{p.user?.firstName} {p.user?.lastName}</td>
|
||||
<td className="text-sm">{p.declaredWeight ? `${p.declaredWeight}lb` : "—"}</td>
|
||||
<td><button className="btn btn-primary btn-sm">Verificar</button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Formulario de verificación */}
|
||||
{selected && (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span className="font-semibold">Verificar: {selected.trackingId}</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div style={{ marginBottom: "1rem", padding: "12px", background: "var(--gray-50)", borderRadius: "8px", fontSize: ".85rem" }}>
|
||||
<strong>Descripción:</strong> {selected.description}<br/>
|
||||
<strong>Tienda:</strong> {selected.store ?? "—"}<br/>
|
||||
<strong>Valor declarado:</strong> ${selected.declaredValue ?? "—"}
|
||||
</div>
|
||||
<form onSubmit={handleVerify} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Peso real (lb) *</label>
|
||||
<input className="form-input" type="number" step="0.01" required
|
||||
value={form.actualWeightLb} onChange={set("actualWeightLb")} placeholder="ej: 2.35" />
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: ".75rem" }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Largo (cm)</label>
|
||||
<input className="form-input" type="number" step="0.1" value={form.lengthCm} onChange={set("lengthCm")} placeholder="cm" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Ancho (cm)</label>
|
||||
<input className="form-input" type="number" step="0.1" value={form.widthCm} onChange={set("widthCm")} placeholder="cm" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Alto (cm)</label>
|
||||
<input className="form-input" type="number" step="0.1" value={form.heightCm} onChange={set("heightCm")} placeholder="cm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Notas</label>
|
||||
<textarea className="form-input" rows={2} value={form.note} onChange={set("note")} placeholder="Notas del operador..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Fotos del paquete (hasta 10)</label>
|
||||
<input ref={fileRef} type="file" multiple accept="image/*" onChange={e => setPhotos(e.target.files)}
|
||||
style={{ fontSize: ".85rem" }} />
|
||||
{photos && photos.length > 0 && (
|
||||
<p style={{ fontSize: ".78rem", color: "var(--green)", marginTop: ".25rem" }}>{photos.length} foto(s) seleccionadas</p>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: ".75rem" }}>
|
||||
<button type="submit" className="btn btn-success" disabled={submitting} style={{ flex: 1 }}>
|
||||
{submitting ? "Verificando…" : "✅ Confirmar verificación"}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setSelected(null)}>Cancelar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = { title: "Cómo funciona la Carga Pesada — Moraworld Imports" };
|
||||
|
||||
export default function CargaPesadaComoFuncionaPage() {
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--gray-50, #F9FAFB)" }}>
|
||||
{/* Hero */}
|
||||
<section style={{ background: "var(--dark, #0D1117)", color: "white", padding: "5rem 2rem 4rem" }}>
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", textAlign: "center" }}>
|
||||
<span style={{ background: "rgba(255,107,0,.2)", color: "var(--orange, #FF6B00)", padding: "4px 14px", borderRadius: "999px", fontSize: ".8rem", fontWeight: 700 }}>B2B · Mayorista</span>
|
||||
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, margin: "1.5rem 0", lineHeight: 1.2 }}>
|
||||
Carga Pesada / Pallets
|
||||
</h1>
|
||||
<p style={{ fontSize: "1.1rem", color: "rgba(255,255,255,.65)", maxWidth: "600px", margin: "0 auto" }}>
|
||||
Importación mayorista desde EE.UU. a Ecuador. Pallets, contenedores, volumen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ maxWidth: "900px", margin: "0 auto", padding: "4rem 2rem" }}>
|
||||
{/* Qué es */}
|
||||
<div style={{ marginBottom: "3rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", fontWeight: 800, marginBottom: "1rem" }}>¿Qué es Carga Pesada?</h2>
|
||||
<p style={{ color: "var(--gray-700, #374151)", lineHeight: 1.8, marginBottom: "1rem" }}>
|
||||
El servicio de Carga Pesada está diseñado para importadores mayoristas que necesitan traer pallets completos, contenedores o volúmenes grandes de mercancía desde EE.UU. a Ecuador. A diferencia del casillero personal, cada operación se cotiza individualmente.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Flujo */}
|
||||
<div style={{ marginBottom: "3rem" }}>
|
||||
<h2 style={{ fontSize: "1.5rem", fontWeight: 800, marginBottom: "1.5rem" }}>Cómo funciona</h2>
|
||||
{[
|
||||
{ step: "01", title: "Solicita cotización", desc: "Completa el formulario con tipo de mercancía, cantidad, peso estimado, número de pallets, valor comercial total y ciudad de origen en EE.UU.", color: "var(--orange)" },
|
||||
{ step: "02", title: "Verificamos INEN", desc: "Para productos regulados (calzado, textiles, electrónicos), verificamos si requieren Registro de Conformidad INEN antes de proceder.", color: "var(--yellow, #F59E0B)" },
|
||||
{ step: "03", title: "Recibe tu cotización", desc: "El equipo Moraworld prepara una cotización personalizada y te la envía por correo y WhatsApp en 24–48 horas.", color: "var(--blue)" },
|
||||
{ step: "04", title: "Acepta y coordina", desc: "Al aceptar, se crea un expediente con ID de seguimiento B2B propio. Coordinamos el embarque, BL, DAS/DAI y certificaciones.", color: "var(--green)" },
|
||||
{ step: "05", title: "Entrega en Ecuador", desc: "Seguimiento completo hasta la entrega en tu ciudad. Declaración formal (DAI) ante la SENAE incluida.", color: "var(--green)" },
|
||||
].map(s => (
|
||||
<div key={s.step} style={{ display: "flex", gap: "1.5rem", marginBottom: "1.5rem" }}>
|
||||
<div style={{ width: "42px", height: "42px", borderRadius: "50%", background: s.color, color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: ".85rem", flexShrink: 0 }}>{s.step}</div>
|
||||
<div>
|
||||
<h3 style={{ fontWeight: 700, marginBottom: ".4rem" }}>{s.title}</h3>
|
||||
<p style={{ color: "var(--gray-600)", lineHeight: 1.7, fontSize: ".9rem" }}>{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Incluye */}
|
||||
<div style={{ background: "white", borderRadius: "12px", border: "1px solid var(--gray-200)", padding: "2rem", marginBottom: "3rem" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 800, marginBottom: "1.25rem" }}>¿Qué incluye el servicio?</h2>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
|
||||
{["Coordinación de embarque desde EE.UU.", "Declaración de Importación (DAI) ante SENAE", "Gestión de partidas arancelarias", "Verificación de certificaciones INEN", "Seguimiento con ID B2B propio", "Asesoría en cumplimiento normativo Ecuador"].map(item => (
|
||||
<div key={item} style={{ display: "flex", gap: ".75rem", alignItems: "flex-start", fontSize: ".9rem", color: "var(--gray-700)" }}>
|
||||
<span style={{ color: "var(--green)", fontWeight: 700, flexShrink: 0 }}>✓</span>{item}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<p style={{ color: "var(--gray-600)", marginBottom: "1.5rem" }}>¿Tienes un proyecto de importación? Cuéntanos.</p>
|
||||
<Link href="/carga-pesada/cotizacion" style={{ background: "var(--orange)", color: "white", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block", marginRight: "1rem" }}>
|
||||
Solicitar cotización →
|
||||
</Link>
|
||||
<Link href="/carga-pesada/inen" style={{ background: "transparent", color: "var(--orange)", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block", border: "2px solid var(--orange)" }}>
|
||||
Info INEN
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = { title: "Normas INEN — Moraworld Imports" };
|
||||
|
||||
export default function InenPage() {
|
||||
const REGULATED = [
|
||||
{ icon: "👟", cat: "Calzado", req: "Registro de Conformidad INEN", norm: "NTE INEN 1672" },
|
||||
{ icon: "👕", cat: "Textiles / ropa",req: "Registro de Conformidad INEN", norm: "NTE INEN 2587" },
|
||||
{ icon: "💡", cat: "Electrónicos", req: "Certificado INEN o exoneración", norm: "Resoluciones COMEX" },
|
||||
{ icon: "🍔", cat: "Alimentos", req: "Registro Sanitario ARCSA", norm: "Normativa sanitaria" },
|
||||
{ icon: "🔧", cat: "Juguetes", req: "Registro de Conformidad INEN", norm: "NTE INEN 1875" },
|
||||
{ icon: "🏠", cat: "Art. del hogar", req: "Varía según producto", norm: "Consultar INEN" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--gray-50, #F9FAFB)" }}>
|
||||
{/* Hero */}
|
||||
<section style={{ background: "var(--dark, #0D1117)", color: "white", padding: "5rem 2rem 4rem" }}>
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", textAlign: "center" }}>
|
||||
<span style={{ background: "rgba(255,107,0,.2)", color: "var(--orange, #FF6B00)", padding: "4px 14px", borderRadius: "999px", fontSize: ".8rem", fontWeight: 700 }}>Normativa Ecuador</span>
|
||||
<h1 style={{ fontSize: "clamp(2rem, 5vw, 2.75rem)", fontWeight: 800, margin: "1.5rem 0", lineHeight: 1.2 }}>
|
||||
Normas INEN para importación
|
||||
</h1>
|
||||
<p style={{ fontSize: "1rem", color: "rgba(255,255,255,.65)", maxWidth: "600px", margin: "0 auto" }}>
|
||||
Ciertos productos requieren certificación de conformidad antes de ingresar a Ecuador en volumen.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={{ maxWidth: "860px", margin: "0 auto", padding: "4rem 2rem" }}>
|
||||
{/* Warning callout */}
|
||||
<div style={{ background: "#FEF3C7", border: "1px solid #FDE68A", borderLeft: "4px solid #F59E0B", borderRadius: "10px", padding: "16px 20px", marginBottom: "2.5rem", display: "flex", gap: "12px" }}>
|
||||
<span style={{ fontSize: "1.1rem" }}>⚠️</span>
|
||||
<div style={{ fontSize: ".88rem", lineHeight: 1.7, color: "#78350F" }}>
|
||||
<strong>Importante:</strong> El incumplimiento de las normas INEN puede resultar en la retención de la mercancía en aduana, multas y la devolución del envío. <strong>Moraworld asiste en verificar el requisito antes de embarcar.</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ¿Qué es INEN? */}
|
||||
<div style={{ marginBottom: "2.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, marginBottom: "1rem" }}>¿Qué es el INEN?</h2>
|
||||
<p style={{ color: "var(--gray-700)", lineHeight: 1.8, marginBottom: "1rem" }}>
|
||||
El <strong>Instituto Ecuatoriano de Normalización (INEN)</strong> es el organismo oficial que establece normas técnicas obligatorias para productos que se comercializan en Ecuador. Para importar ciertos productos en volumen, se requiere un <strong>Registro de Conformidad INEN</strong> o un certificado equivalente.
|
||||
</p>
|
||||
<p style={{ color: "var(--gray-700)", lineHeight: 1.8 }}>
|
||||
El Registro de Conformidad se obtiene antes de la importación y tiene vigencia limitada. El proceso varía según el producto y puede requerir pruebas de laboratorio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabla de productos regulados */}
|
||||
<div style={{ marginBottom: "2.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.4rem", fontWeight: 800, marginBottom: "1.25rem" }}>Productos que requieren certificación</h2>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem" }}>
|
||||
{REGULATED.map(r => (
|
||||
<div key={r.cat} style={{ background: "white", border: "1px solid var(--gray-200)", borderRadius: "10px", padding: "16px 18px" }}>
|
||||
<div style={{ fontSize: "1.5rem", marginBottom: ".5rem" }}>{r.icon}</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: ".4rem" }}>{r.cat}</div>
|
||||
<div style={{ fontSize: ".82rem", color: "var(--gray-600)", marginBottom: ".4rem" }}>{r.req}</div>
|
||||
<div style={{ fontSize: ".75rem", fontFamily: "monospace", color: "var(--blue)", background: "var(--blue-50, #EEF3FF)", padding: "2px 8px", borderRadius: "4px", display: "inline-block" }}>{r.norm}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proceso */}
|
||||
<div style={{ background: "white", border: "1px solid var(--gray-200)", borderRadius: "12px", padding: "2rem", marginBottom: "2.5rem" }}>
|
||||
<h2 style={{ fontSize: "1.3rem", fontWeight: 800, marginBottom: "1.25rem" }}>¿Cómo te ayudamos?</h2>
|
||||
{[
|
||||
"Verificamos si tu producto requiere certificación antes de embarcarlo.",
|
||||
"Identificamos la norma técnica aplicable y el organismo certificador.",
|
||||
"Orientamos en el proceso para obtener el Registro de Conformidad.",
|
||||
"Coordinamos con el importador para tener toda la documentación lista.",
|
||||
].map((t, i) => (
|
||||
<div key={i} style={{ display: "flex", gap: ".75rem", marginBottom: ".75rem", fontSize: ".9rem", color: "var(--gray-700)" }}>
|
||||
<span style={{ color: "var(--green)", fontWeight: 700, flexShrink: 0 }}>✓</span>{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div style={{ textAlign: "center", padding: "2rem 0" }}>
|
||||
<p style={{ color: "var(--gray-600)", marginBottom: "1.5rem", fontSize: ".95rem" }}>
|
||||
¿Tienes dudas sobre si tu producto requiere certificación? Consúltanos antes de importar.
|
||||
</p>
|
||||
<Link href="/carga-pesada/cotizacion" style={{ background: "var(--orange)", color: "white", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block" }}>
|
||||
Solicitar asesoría →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata = { title: "Cómo usar tu casillero — Moraworld Imports" };
|
||||
|
||||
export default function ComoUsarCasilleroPage() {
|
||||
const STEPS = [
|
||||
{ num: "01", icon: "📝", title: "Regístrate gratis", desc: "Crea tu cuenta en moraworldimports.com con tu email y contraseña. Activa la verificación en dos pasos (MFA) para mayor seguridad." },
|
||||
{ num: "02", icon: "📫", title: "Recibe tu Suite personalizada", desc: "El sistema te asigna automáticamente una dirección única: 150 N Day St, Suite EC-XXXXX, City of Orange, NJ 07050. Es tu casillero en New Jersey." },
|
||||
{ num: "03", icon: "🛒", title: "Compra en tiendas online de EE.UU.", desc: "Usa tu Suite como dirección de entrega en Amazon, eBay, Walmart, Target, Best Buy y cualquier tienda que envíe a New Jersey." },
|
||||
{ num: "04", icon: "📣", title: "Registra una pre-alerta (opcional)", desc: "Avísanos qué paquete esperas, carga la factura y el tracking del vendedor. La bodega se organiza con anticipación y el proceso es más rápido." },
|
||||
{ num: "05", icon: "📦", title: "Recibimos tu paquete en NJ", desc: "El equipo de Mora Global Import LLC recibe, pesa, mide y fotografía tu paquete. Recibes notificación automática por WhatsApp y correo." },
|
||||
{ num: "06", icon: "💰", title: "Calcula y paga el envío", desc: "El sistema calcula automáticamente: flete (peso × $3.50/lb), seguro 2%, impuestos SENAE (FODINFA 0.5%, IVA 15%, arancel según categoría)." },
|
||||
{ num: "07", icon: "🛃", title: "Declaración aduanera automática", desc: "El agente aduanero genera la Declaración Simplificada (DSI) ante la SENAE. Si el valor supera $400, se inicia el proceso formal (DAI)." },
|
||||
{ num: "08", icon: "✈️", title: "Envío a Ecuador", desc: "Tu paquete viaja vía courier internacional (FedEx / DHL / UPS) a Ecuador. Recibes el tracking en tiempo real." },
|
||||
{ num: "09", icon: "🎉", title: "Recibe en Ecuador", desc: "Coordina la entrega en tu ciudad. Estado final: ENTREGADO." },
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--gray-50, #F9FAFB)" }}>
|
||||
{/* Hero */}
|
||||
<section style={{ background: "var(--dark, #0D1117)", color: "white", padding: "5rem 2rem 4rem" }}>
|
||||
<div style={{ maxWidth: "800px", margin: "0 auto", textAlign: "center" }}>
|
||||
<p style={{ fontSize: ".85rem", color: "var(--orange, #FF6B00)", fontWeight: 700, letterSpacing: ".1em", textTransform: "uppercase", marginBottom: "1rem" }}>Paso a paso</p>
|
||||
<h1 style={{ fontSize: "clamp(2rem, 5vw, 3rem)", fontWeight: 800, marginBottom: "1.5rem", lineHeight: 1.2 }}>
|
||||
Cómo usar tu casillero
|
||||
</h1>
|
||||
<p style={{ fontSize: "1.1rem", color: "rgba(255,255,255,.65)", maxWidth: "600px", margin: "0 auto 2rem" }}>
|
||||
Recibe tus compras de Amazon, eBay y Walmart directamente en Ecuador. 9 pasos simples.
|
||||
</p>
|
||||
<div style={{ fontFamily: "monospace", fontSize: ".95rem", background: "rgba(0,87,255,.15)", border: "1px solid rgba(0,87,255,.3)", borderRadius: "10px", padding: "1.25rem 1.75rem", display: "inline-block", color: "#A5B4FC", lineHeight: 1.8 }}>
|
||||
<span style={{ color: "rgba(255,255,255,.4)" }}>Tu dirección NJ:</span><br/>
|
||||
<strong style={{ color: "white" }}>150 N Day St, Suite EC-XXXXX</strong><br/>
|
||||
City of Orange, NJ 07050, EE.UU.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Steps */}
|
||||
<section style={{ maxWidth: "800px", margin: "0 auto", padding: "4rem 2rem" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0" }}>
|
||||
{STEPS.map((s, i) => (
|
||||
<div key={s.num} style={{ display: "flex", gap: "1.5rem", paddingBottom: "2rem", position: "relative" }}>
|
||||
{i < STEPS.length - 1 && (
|
||||
<div style={{ position: "absolute", left: "20px", top: "48px", bottom: "0", width: "2px", background: "var(--gray-200, #E5E7EB)" }} />
|
||||
)}
|
||||
<div style={{ width: "42px", height: "42px", borderRadius: "50%", background: "var(--blue, #0057FF)", color: "white", display: "flex", alignItems: "center", justifyContent: "center", fontWeight: 800, fontSize: ".85rem", flexShrink: 0, zIndex: 1 }}>
|
||||
{s.num}
|
||||
</div>
|
||||
<div style={{ flex: 1, paddingTop: ".5rem" }}>
|
||||
<h3 style={{ fontWeight: 700, fontSize: "1rem", marginBottom: ".5rem" }}>
|
||||
{s.icon} {s.title}
|
||||
</h3>
|
||||
<p style={{ fontSize: ".9rem", color: "var(--gray-600, #4B5563)", lineHeight: 1.7 }}>{s.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section style={{ background: "var(--blue, #0057FF)", color: "white", padding: "4rem 2rem", textAlign: "center" }}>
|
||||
<h2 style={{ fontSize: "1.75rem", fontWeight: 800, marginBottom: "1rem" }}>¿Listo para comenzar?</h2>
|
||||
<p style={{ color: "rgba(255,255,255,.75)", marginBottom: "2rem" }}>Crea tu cuenta gratis y recibe tu Suite en segundos.</p>
|
||||
<Link href="/registro" style={{ background: "white", color: "var(--blue)", padding: "14px 40px", borderRadius: "8px", fontWeight: 700, textDecoration: "none", display: "inline-block" }}>
|
||||
Crear cuenta gratis →
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,80 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
type Tab = "url" | "manual";
|
||||
type Status = "PENDIENTE" | "VINCULADA" | "CANCELADA";
|
||||
|
||||
const STATUS_BADGE: Record<Status, string> = {
|
||||
PENDIENTE: "badge-yellow",
|
||||
VINCULADA: "badge-green",
|
||||
CANCELADA: "badge-red",
|
||||
};
|
||||
|
||||
export default function PreAlertaPage() {
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<Tab>("manual");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [form, setForm] = useState({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [invoice, setInvoice] = useState<File | null>(null);
|
||||
const invoiceRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const load = () => { api.preAlerts.list().then(setAlerts).catch(() => {}).finally(() => setLoading(false)); };
|
||||
const [form, setForm] = useState({
|
||||
store: "",
|
||||
vendorTracking: "",
|
||||
description: "",
|
||||
declaredValue: "",
|
||||
estimatedArrival: "",
|
||||
});
|
||||
|
||||
const load = () => {
|
||||
api.preAlerts.list().then(setAlerts).catch(() => {}).finally(() => setLoading(false));
|
||||
};
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
setForm(f => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
// Opción A — escanear URL
|
||||
const handleScanUrl = async () => {
|
||||
if (!urlInput.trim()) return;
|
||||
setScanning(true); setError("");
|
||||
try {
|
||||
const product = await api.products.scan(urlInput.trim());
|
||||
setForm({
|
||||
store: product.store ?? "",
|
||||
vendorTracking: "",
|
||||
description: product.name ?? "",
|
||||
declaredValue: String(product.price ?? ""),
|
||||
estimatedArrival: "",
|
||||
});
|
||||
setTab("manual");
|
||||
setSuccess("✅ Datos extraídos del producto. Revisa y completa el formulario.");
|
||||
} catch (err: any) {
|
||||
setError("No se pudo extraer el producto. Intenta con el formulario manual.");
|
||||
} finally { setScanning(false); }
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true); setError(""); setSuccess("");
|
||||
try {
|
||||
await api.preAlerts.create({
|
||||
...form,
|
||||
estimatedValueUsd: form.estimatedValueUsd ? parseFloat(form.estimatedValueUsd) : undefined,
|
||||
estimatedWeightLb: form.estimatedWeightLb ? parseFloat(form.estimatedWeightLb) : undefined,
|
||||
store: form.store,
|
||||
vendorTracking: form.vendorTracking || undefined,
|
||||
description: form.description,
|
||||
declaredValue: parseFloat(form.declaredValue),
|
||||
estimatedArrival: form.estimatedArrival || undefined,
|
||||
});
|
||||
setSuccess("Pre-alerta registrada exitosamente.");
|
||||
setForm({ store: "", orderNumber: "", description: "", estimatedValueUsd: "", estimatedWeightLb: "", trackingNumber: "" });
|
||||
setSuccess("✅ Pre-alerta registrada exitosamente.");
|
||||
setForm({ store: "", vendorTracking: "", description: "", declaredValue: "", estimatedArrival: "" });
|
||||
setUrlInput(""); setInvoice(null);
|
||||
load();
|
||||
} catch (err: any) { setError(err.message ?? "Error"); }
|
||||
} catch (err: any) { setError(err.message ?? "Error al registrar"); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
@@ -38,83 +84,133 @@ export default function PreAlertaPage() {
|
||||
load();
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
PENDIENTE: "badge-yellow", RECIBIDO: "badge-blue", VINCULADO: "badge-green", RECHAZADO: "badge-red",
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="dash-page-title">Pre-Alertas</h1>
|
||||
<p className="dash-page-subtitle">Avísanos qué paquetes esperas para procesarlos más rápido.</p>
|
||||
<p className="dash-page-subtitle">Avísanos qué paquetes esperas. Con pre-alerta, la bodega en NJ te organiza todo más rápido.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ gap: "1.5rem", alignItems: "start" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
|
||||
{/* Formulario */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Nueva Pre-Alerta</span></div>
|
||||
<div className="card-header">
|
||||
<span className="font-semibold">Nueva Pre-Alerta</span>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{success && <div className="alert alert-success mb-4">{success}</div>}
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
{error && <div className="alert alert-error mb-4">{error}</div>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.25rem", borderBottom: "2px solid var(--gray-100)", paddingBottom: ".75rem" }}>
|
||||
<button onClick={() => setTab("url")}
|
||||
style={{ padding: "6px 16px", borderRadius: "6px", fontSize: ".82rem", fontWeight: 600, border: "none", cursor: "pointer",
|
||||
background: tab === "url" ? "var(--blue)" : "transparent",
|
||||
color: tab === "url" ? "white" : "var(--gray-500)" }}>
|
||||
🔗 Por enlace (URL)
|
||||
</button>
|
||||
<button onClick={() => setTab("manual")}
|
||||
style={{ padding: "6px 16px", borderRadius: "6px", fontSize: ".82rem", fontWeight: 600, border: "none", cursor: "pointer",
|
||||
background: tab === "manual" ? "var(--blue)" : "transparent",
|
||||
color: tab === "manual" ? "white" : "var(--gray-500)" }}>
|
||||
✏️ Manual
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab: URL scan */}
|
||||
{tab === "url" && (
|
||||
<div style={{ marginBottom: "1rem" }}>
|
||||
<p style={{ fontSize: ".85rem", color: "var(--gray-600)", marginBottom: ".75rem" }}>
|
||||
Pega la URL del producto de Amazon, eBay o Walmart y extraemos los datos automáticamente.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: ".5rem" }}>
|
||||
<input className="form-input" style={{ flex: 1 }} type="url" placeholder="https://amazon.com/dp/..."
|
||||
value={urlInput} onChange={e => setUrlInput(e.target.value)} />
|
||||
<button className="btn btn-primary" disabled={scanning || !urlInput.trim()} onClick={handleScanUrl}>
|
||||
{scanning ? "Escaneando…" : "Escanear"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab: Manual form */}
|
||||
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Tienda *</label>
|
||||
<input className="input" placeholder="Amazon, eBay, Shein…" value={form.store} onChange={set("store")} required />
|
||||
<label className="form-label">Tienda *</label>
|
||||
<input className="form-input" required value={form.store} onChange={set("store")}
|
||||
placeholder="Amazon, eBay, Walmart…" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Número de orden *</label>
|
||||
<input className="input" placeholder="123-4567890-1234567" value={form.orderNumber} onChange={set("orderNumber")} required />
|
||||
<label className="form-label">Descripción del producto *</label>
|
||||
<textarea className="form-input" required rows={2} value={form.description} onChange={set("description")}
|
||||
placeholder="Ej: Auriculares Sony WH-1000XM5 negros" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Descripción</label>
|
||||
<textarea className="textarea" placeholder="Descripción del producto" value={form.description} onChange={set("description")} style={{ minHeight: 70 }} />
|
||||
</div>
|
||||
<div className="grid-2" style={{ gap: "1rem" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
|
||||
<div className="form-group">
|
||||
<label className="label">Valor est. (USD)</label>
|
||||
<input type="number" className="input" placeholder="50.00" min={0} step="0.01" value={form.estimatedValueUsd} onChange={set("estimatedValueUsd")} />
|
||||
<label className="form-label">Valor declarado (USD) *</label>
|
||||
<input className="form-input" required type="number" step="0.01" min="0" value={form.declaredValue}
|
||||
onChange={set("declaredValue")} placeholder="0.00" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Peso est. (lb)</label>
|
||||
<input type="number" className="input" placeholder="2.5" min={0} step="0.1" value={form.estimatedWeightLb} onChange={set("estimatedWeightLb")} />
|
||||
<label className="form-label">Tracking del vendedor</label>
|
||||
<input className="form-input" value={form.vendorTracking} onChange={set("vendorTracking")}
|
||||
placeholder="1Z999AA10123456784" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="label">Tracking del proveedor</label>
|
||||
<input className="input" placeholder="1Z999AA10123456784" value={form.trackingNumber} onChange={set("trackingNumber")} />
|
||||
<label className="form-label">Fecha estimada de llegada a NJ</label>
|
||||
<input className="form-input" type="date" value={form.estimatedArrival} onChange={set("estimatedArrival")} />
|
||||
</div>
|
||||
{/* Invoice file — stored locally, shown to user for UX */}
|
||||
<div className="form-group">
|
||||
<label className="form-label">Factura / Comprobante (PDF o imagen)</label>
|
||||
<input ref={invoiceRef} type="file" accept=".pdf,image/*"
|
||||
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: ".75rem", color: "var(--gray-400)", marginTop: ".25rem" }}>
|
||||
Próximamente: la factura se enviará automáticamente a la bodega.
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-primary" disabled={submitting}>
|
||||
{submitting ? "Enviando…" : "Registrar Pre-Alerta"}
|
||||
{submitting ? "Registrando…" : "Registrar pre-alerta"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista */}
|
||||
{/* Lista de pre-alertas */}
|
||||
<div className="card">
|
||||
<div className="card-header"><span className="font-semibold">Mis Pre-Alertas</span></div>
|
||||
<div style={{ padding: loading ? "2rem" : 0, textAlign: "center" }}>
|
||||
{loading ? <div className="spinner mx-auto" /> :
|
||||
alerts.length === 0 ? (
|
||||
<p style={{ padding: "2rem", color: "var(--gray-500)" }}>No tienes pre-alertas.</p>
|
||||
) : (
|
||||
alerts.map(a => (
|
||||
<div key={a.id} style={{ padding: "1rem 1.5rem", borderBottom: "1px solid var(--gray-100)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{a.store}</div>
|
||||
<div style={{ fontSize: ".8rem", color: "var(--gray-500)" }}>Orden: {a.orderNumber}</div>
|
||||
{a.description && <div style={{ fontSize: ".8rem", color: "var(--gray-400)" }}>{a.description}</div>}
|
||||
</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: ".4rem" }}>
|
||||
<span className={`badge ${STATUS_BADGE[a.status] ?? "badge-gray"}`}>{a.status}</span>
|
||||
{a.status === "PENDIENTE" && (
|
||||
<button className="btn btn-ghost btn-sm text-red" style={{ fontSize: ".75rem", color: "var(--red)" }} onClick={() => handleDelete(a.id)}>Eliminar</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="card-header"><span className="font-semibold">Mis pre-alertas</span></div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><div className="spinner" /></div>
|
||||
) : alerts.length === 0 ? (
|
||||
<div style={{ padding: "2rem", textAlign: "center", color: "var(--gray-500)", fontSize: ".9rem" }}>
|
||||
No tienes pre-alertas registradas.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: ".75rem", padding: "1rem" }}>
|
||||
{alerts.map(a => (
|
||||
<div key={a.id} style={{ background: "var(--gray-50)", borderRadius: "8px", padding: "12px 14px", border: "1px solid var(--gray-200)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: ".5rem" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: ".9rem" }}>{a.store}</span>
|
||||
<span className={`badge ${STATUS_BADGE[a.status as Status] ?? "badge-gray"}`}>{a.status}</span>
|
||||
</div>
|
||||
<p style={{ fontSize: ".82rem", color: "var(--gray-700)", marginBottom: ".4rem" }}>{a.description}</p>
|
||||
<div style={{ display: "flex", gap: "1rem", fontSize: ".78rem", color: "var(--gray-500)" }}>
|
||||
{a.declaredValue && <span>💰 ${a.declaredValue}</span>}
|
||||
{a.vendorTracking && <span>📦 {a.vendorTracking}</span>}
|
||||
</div>
|
||||
{a.status === "PENDIENTE" && (
|
||||
<button className="btn btn-ghost btn-sm" style={{ marginTop: ".5rem", color: "var(--red)", fontSize: ".78rem" }}
|
||||
onClick={() => handleDelete(a.id)}>Eliminar</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,6 +94,17 @@ export const api = {
|
||||
get: (id: string) => request<any>(`/packages/${id}`),
|
||||
create: (body: any) => request<any>("/packages", { method: "POST", body: JSON.stringify(body) }),
|
||||
updateStatus: (id: string, body: any) => request<any>(`/packages/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
verify: (id: string, body: any) => request<any>(`/packages/${id}/verify`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
pendingDeclaration: () => request<any[]>("/packages/pending-declaration"),
|
||||
uploadPhotos: (id: string, formData: FormData) => {
|
||||
const token = getToken();
|
||||
return fetch(`${API_BASE}/packages/${id}/photos`, {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
}).then(r => { if (!r.ok) throw new Error("Upload failed"); return r.json(); });
|
||||
},
|
||||
senaeDeclare: (id: string, body: any) => request<any>(`/packages/${id}/senae/declare`, { method: "POST", body: JSON.stringify(body) }),
|
||||
},
|
||||
preAlerts: {
|
||||
list: () => request<any[]>("/pre-alerts"),
|
||||
@@ -118,4 +129,14 @@ export const api = {
|
||||
list: () => request<any[]>("/b2b"),
|
||||
updateStatus: (id: string, body: any) => request<any>(`/b2b/${id}/status`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
},
|
||||
tariffs: {
|
||||
get: () => request<any>("/tariffs"),
|
||||
update: (body: any) => request<any>("/tariffs", { method: "PUT", body: JSON.stringify(body) }),
|
||||
},
|
||||
auditLogs: {
|
||||
list: (params?: Record<string,string>) => request<any>("/audit-logs" + (params ? "?" + new URLSearchParams(params) : "")),
|
||||
},
|
||||
products: {
|
||||
scan: (url: string) => request<any>("/products/scan", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user