feat: add Warehouses + Integrations modules, /admin/configuracion page
- Schema v0.3: Warehouse + Integration models (db push applied) - WarehousesModule: CRUD, set-default, multi-warehouse support - IntegrationsModule: 26 keys across 7 groups (payment, notifications, customs, courier, marketplace, compliance, warehouse) - AuthService: suite address pulled from default Warehouse in DB (env fallback) - AuthModule: imports WarehousesModule - Seed: creates default warehouse from WAREHOUSE_ADDRESS_* env vars - Web: /admin/configuracion page (3 tabs: Bodegas, Integraciones, Estado APIs) - Web: admin layout adds Configuracion nav link - api.ts: warehouses + integrations client methods
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
type Tab = "bodegas" | "integraciones" | "estado";
|
||||
|
||||
// ─── Integration groups displayed in order ────────────────────────────────────
|
||||
const GROUPS: Array<{ key: string; label: string; icon: string }> = [
|
||||
{ key: "payment", label: "Pasarela de Pagos", icon: "💳" },
|
||||
{ key: "notifications", label: "Notificaciones", icon: "🔔" },
|
||||
{ key: "customs", label: "Aduana / SENAE", icon: "🛃" },
|
||||
{ key: "courier", label: "Couriers", icon: "📦" },
|
||||
{ key: "marketplace", label: "Amazon SP-API", icon: "🛒" },
|
||||
{ key: "compliance", label: "INEN", icon: "📋" },
|
||||
{ key: "warehouse", label: "Software de Bodega", icon: "🏭" },
|
||||
];
|
||||
|
||||
// ─── Warehouse form (create / edit) ──────────────────────────────────────────
|
||||
const EMPTY_WH = { name: "", street: "", city: "", state: "", zip: "", country: "US", phone: "", email: "", contactName: "", schedule: "" };
|
||||
|
||||
export default function ConfiguracionPage() {
|
||||
const [tab, setTab] = useState<Tab>("bodegas");
|
||||
|
||||
// ── Bodegas state ──
|
||||
const [warehouses, setWarehouses] = useState<any[]>([]);
|
||||
const [whLoading, setWhLoading] = useState(true);
|
||||
const [whError, setWhError] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<any | null>(null); // null = closed, {} = create, {id,..} = edit
|
||||
const [whForm, setWhForm] = useState<typeof EMPTY_WH>(EMPTY_WH);
|
||||
const [whSaving, setWhSaving] = useState(false);
|
||||
|
||||
// ── Integrations state ──
|
||||
const [integrations, setIntegrations] = useState<any[]>([]);
|
||||
const [intLoading, setIntLoading] = useState(false);
|
||||
const [intSaving, setIntSaving] = useState(false);
|
||||
const [intEdits, setIntEdits] = useState<Record<string, string>>({}); // key → raw value
|
||||
const [intActive, setIntActive] = useState<Record<string, boolean>>({}); // key → isActive
|
||||
const [intSuccess, setIntSuccess] = useState(false);
|
||||
|
||||
// ── API status state ──
|
||||
const [statusData, setStatusData] = useState<any | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
|
||||
// ── Load warehouses ──
|
||||
const loadWarehouses = useCallback(async () => {
|
||||
setWhLoading(true); setWhError(null);
|
||||
try { setWarehouses(await api.warehouses.list()); }
|
||||
catch (e: any) { setWhError(e.message); }
|
||||
finally { setWhLoading(false); }
|
||||
}, []);
|
||||
|
||||
// ── Load integrations ──
|
||||
const loadIntegrations = useCallback(async () => {
|
||||
setIntLoading(true);
|
||||
try {
|
||||
const data = await api.integrations.list();
|
||||
setIntegrations(data);
|
||||
const edits: Record<string, string> = {};
|
||||
const active: Record<string, boolean> = {};
|
||||
data.forEach((i: any) => {
|
||||
edits[i.key] = ""; // never pre-fill secret values
|
||||
active[i.key] = i.isActive;
|
||||
});
|
||||
setIntEdits(edits);
|
||||
setIntActive(active);
|
||||
} catch {}
|
||||
finally { setIntLoading(false); }
|
||||
}, []);
|
||||
|
||||
// ── Load API status ──
|
||||
const loadStatus = useCallback(async () => {
|
||||
setStatusLoading(true);
|
||||
try { setStatusData(await api.integrations.status()); }
|
||||
catch {}
|
||||
finally { setStatusLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWarehouses(); }, [loadWarehouses]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "integraciones" && integrations.length === 0) loadIntegrations();
|
||||
if (tab === "estado") loadStatus();
|
||||
}, [tab, integrations.length, loadIntegrations, loadStatus]);
|
||||
|
||||
// ─── Warehouse handlers ───────────────────────────────────────────────────
|
||||
const openCreate = () => { setWhForm(EMPTY_WH); setEditing({}); };
|
||||
const openEdit = (wh: any) => { setWhForm({ name: wh.name, street: wh.street, city: wh.city, state: wh.state, zip: wh.zip, country: wh.country ?? "US", phone: wh.phone ?? "", email: wh.email ?? "", contactName: wh.contactName ?? "", schedule: wh.schedule ?? "" }); setEditing(wh); };
|
||||
const closeForm = () => setEditing(null);
|
||||
|
||||
const saveWarehouse = async () => {
|
||||
setWhSaving(true);
|
||||
try {
|
||||
if (editing?.id) {
|
||||
await api.warehouses.update(editing.id, whForm);
|
||||
} else {
|
||||
await api.warehouses.create(whForm);
|
||||
}
|
||||
await loadWarehouses();
|
||||
closeForm();
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally { setWhSaving(false); }
|
||||
};
|
||||
|
||||
const setDefault = async (id: string) => {
|
||||
try { await api.warehouses.setDefault(id); await loadWarehouses(); }
|
||||
catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
const removeWarehouse = async (id: string) => {
|
||||
if (!confirm("¿Eliminar esta bodega?")) return;
|
||||
try { await api.warehouses.remove(id); await loadWarehouses(); }
|
||||
catch (e: any) { alert(e.message); }
|
||||
};
|
||||
|
||||
// ─── Integration handlers ─────────────────────────────────────────────────
|
||||
const saveIntegrations = async () => {
|
||||
setIntSaving(true); setIntSuccess(false);
|
||||
try {
|
||||
const items = integrations.map((i: any) => ({
|
||||
key: i.key,
|
||||
value: intEdits[i.key]?.trim() || null,
|
||||
isActive: intActive[i.key] ?? false,
|
||||
}));
|
||||
await api.integrations.batchUpsert(items);
|
||||
setIntSuccess(true);
|
||||
await loadIntegrations();
|
||||
setTimeout(() => setIntSuccess(false), 3000);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally { setIntSaving(false); }
|
||||
};
|
||||
|
||||
const groupedIntegrations = GROUPS.map(g => ({
|
||||
...g,
|
||||
items: integrations.filter((i: any) => i.group === g.key),
|
||||
})).filter(g => g.items.length > 0);
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Configuración</h1>
|
||||
<p className="page-subtitle">Bodegas, integraciones externas y estado de APIs</p>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{ display: "flex", gap: ".5rem", marginBottom: "1.5rem", borderBottom: "1px solid var(--gray-200)", paddingBottom: ".5rem" }}>
|
||||
{([ ["bodegas","🏭","Bodegas"], ["integraciones","🔌","Integraciones"], ["estado","📡","Estado APIs"] ] as const).map(([key, icon, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
className={`btn btn-sm ${tab === key ? "btn-primary" : "btn-ghost"}`}>
|
||||
{icon} {label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── TAB: BODEGAS ─────────────────────────────────────────────────── */}
|
||||
{tab === "bodegas" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", fontWeight: 600 }}>Bodegas registradas</h2>
|
||||
<button className="btn btn-primary btn-sm" onClick={openCreate}>+ Nueva bodega</button>
|
||||
</div>
|
||||
|
||||
{whLoading && <div className="loading-overlay" style={{ position: "relative", height: 80 }}><div className="spinner" /></div>}
|
||||
{whError && <div className="alert alert-error">{whError}</div>}
|
||||
|
||||
{!whLoading && warehouses.length === 0 && (
|
||||
<div className="empty-state">
|
||||
<p>No hay bodegas registradas.</p>
|
||||
<button className="btn btn-primary" onClick={openCreate}>+ Crear primera bodega</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: "1rem" }}>
|
||||
{warehouses.map(wh => (
|
||||
<div key={wh.id} className="card" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", flexWrap: "wrap", gap: ".75rem" }}>
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: ".5rem", marginBottom: ".25rem" }}>
|
||||
<strong>{wh.name}</strong>
|
||||
{wh.isDefault && <span className="badge badge-success">Predeterminada</span>}
|
||||
{!wh.isActive && <span className="badge badge-error">Inactiva</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: ".875rem", color: "var(--gray-600)", lineHeight: 1.6 }}>
|
||||
{wh.street}, {wh.city}, {wh.state} {wh.zip}, {wh.country}
|
||||
{wh.phone && <> • {wh.phone}</>}
|
||||
{wh.schedule && <><br />{wh.schedule}</>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: ".5rem", flexWrap: "wrap" }}>
|
||||
{!wh.isDefault && (
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => setDefault(wh.id)}>Hacer predeterminada</button>
|
||||
)}
|
||||
<button className="btn btn-ghost btn-sm" onClick={() => openEdit(wh)}>Editar</button>
|
||||
{!wh.isDefault && (
|
||||
<button className="btn btn-ghost btn-sm" style={{ color: "var(--error)" }} onClick={() => removeWarehouse(wh.id)}>Eliminar</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Warehouse form modal ── */}
|
||||
{editing !== null && (
|
||||
<div className="modal-overlay" onClick={closeForm}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 560 }}>
|
||||
<div className="modal-header">
|
||||
<h3>{editing?.id ? "Editar bodega" : "Nueva bodega"}</h3>
|
||||
<button className="btn-icon" onClick={closeForm}>✕</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: ".75rem" }}>
|
||||
{([
|
||||
["name","Nombre","text",2],
|
||||
["street","Dirección","text",2],
|
||||
["city","Ciudad","text",1],
|
||||
["state","Estado/Provincia","text",1],
|
||||
["zip","Código Postal","text",1],
|
||||
["country","País (ISO)","text",1],
|
||||
["phone","Teléfono","text",1],
|
||||
["email","Email","email",1],
|
||||
["contactName","Nombre de contacto","text",2],
|
||||
["schedule","Horario (texto libre)","text",2],
|
||||
] as Array<[keyof typeof EMPTY_WH, string, string, number]>).map(([key, label, type, cols]) => (
|
||||
<div key={key} style={{ gridColumn: `span ${cols}` }}>
|
||||
<label className="form-label">{label}</label>
|
||||
<input className="form-control" type={type} value={whForm[key]}
|
||||
onChange={e => setWhForm(f => ({ ...f, [key]: e.target.value }))} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="btn btn-ghost" onClick={closeForm} disabled={whSaving}>Cancelar</button>
|
||||
<button className="btn btn-primary" onClick={saveWarehouse} disabled={whSaving}>
|
||||
{whSaving ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TAB: INTEGRACIONES ───────────────────────────────────────────── */}
|
||||
{tab === "integraciones" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<p style={{ fontSize: ".875rem", color: "var(--gray-600)" }}>
|
||||
Los valores ingresados reemplazan las claves actuales. Deja en blanco para no modificar.
|
||||
</p>
|
||||
{intSuccess && <span className="badge badge-success">Guardado correctamente</span>}
|
||||
<button className="btn btn-primary btn-sm" onClick={saveIntegrations} disabled={intSaving}>
|
||||
{intSaving ? "Guardando..." : "Guardar todo"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{intLoading && <div className="loading-overlay" style={{ position: "relative", height: 80 }}><div className="spinner" /></div>}
|
||||
|
||||
{groupedIntegrations.map(group => (
|
||||
<div key={group.key} className="card" style={{ marginBottom: "1rem" }}>
|
||||
<h3 style={{ fontSize: "1rem", fontWeight: 600, marginBottom: "1rem" }}>{group.icon} {group.label}</h3>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: ".75rem" }}>
|
||||
{group.items.map((item: any) => (
|
||||
<div key={item.key}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: ".5rem", marginBottom: ".25rem" }}>
|
||||
<label className="form-label" style={{ marginBottom: 0 }}>{item.label}</label>
|
||||
{item.required && <span style={{ fontSize: ".7rem", color: "var(--error)" }}>*</span>}
|
||||
<label style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: ".25rem", fontSize: ".8rem", cursor: "pointer" }}>
|
||||
<input type="checkbox" checked={intActive[item.key] ?? false}
|
||||
onChange={e => setIntActive(a => ({ ...a, [item.key]: e.target.checked }))} />
|
||||
Activo
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ position: "relative" }}>
|
||||
<input
|
||||
className="form-control"
|
||||
type="password"
|
||||
placeholder={item.hasValue ? "•••••••• (tiene valor)" : "Sin configurar"}
|
||||
value={intEdits[item.key] ?? ""}
|
||||
onChange={e => setIntEdits(d => ({ ...d, [item.key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!intLoading && groupedIntegrations.length === 0 && (
|
||||
<div className="empty-state">No hay integraciones definidas.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── TAB: ESTADO APIs ─────────────────────────────────────────────── */}
|
||||
{tab === "estado" && (
|
||||
<div>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1rem" }}>
|
||||
<h2 style={{ fontSize: "1.1rem", fontWeight: 600 }}>Estado de integraciones</h2>
|
||||
<button className="btn btn-ghost btn-sm" onClick={loadStatus} disabled={statusLoading}>
|
||||
{statusLoading ? "Actualizando..." : "Actualizar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statusLoading && <div className="loading-overlay" style={{ position: "relative", height: 80 }}><div className="spinner" /></div>}
|
||||
|
||||
{statusData && (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(260px, 1fr))", gap: "1rem" }}>
|
||||
{GROUPS.filter(g => statusData[g.key]).map(g => {
|
||||
const s = statusData[g.key];
|
||||
return (
|
||||
<div key={g.key} className="card" style={{ borderLeft: `3px solid ${s.connected ? "var(--success)" : "var(--warning)"}` }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: ".5rem", marginBottom: ".5rem" }}>
|
||||
<span>{g.icon}</span>
|
||||
<strong>{g.label}</strong>
|
||||
<span className={`badge ${s.connected ? "badge-success" : "badge-warning"}`} style={{ marginLeft: "auto" }}>
|
||||
{s.connected ? "Conectado" : "Incompleto"}
|
||||
</span>
|
||||
</div>
|
||||
{!s.connected && s.missing.length > 0 && (
|
||||
<ul style={{ fontSize: ".8rem", color: "var(--warning)", margin: 0, paddingLeft: "1rem" }}>
|
||||
{s.missing.map((m: string) => <li key={m}>{m}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{s.connected && (
|
||||
<p style={{ fontSize: ".8rem", color: "var(--success)", margin: 0 }}>Todos los campos requeridos configurados.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!statusLoading && !statusData && (
|
||||
<div className="empty-state">
|
||||
<p>Carga el estado de las APIs.</p>
|
||||
<button className="btn btn-primary" onClick={loadStatus}>Cargar estado</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,13 @@ import { getUser, clearAuth } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/admin", icon: "◈", label: "Dashboard" },
|
||||
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
|
||||
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
||||
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
||||
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
|
||||
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
|
||||
{ href: "/admin", icon: "◈", label: "Dashboard" },
|
||||
{ href: "/admin/usuarios", icon: "👥", label: "Usuarios" },
|
||||
{ href: "/admin/tarifas", icon: "💰", label: "Tarifas" },
|
||||
{ href: "/admin/configuracion", icon: "⚙️", label: "Configuración" },
|
||||
{ href: "/admin/reportes", icon: "📊", label: "Reportes" },
|
||||
{ href: "/admin/auditoria", icon: "🔍", label: "Auditoría" },
|
||||
{ href: "/bodega", icon: "📦", label: "→ Bodega" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -139,4 +139,19 @@ export const api = {
|
||||
products: {
|
||||
scan: (url: string) => request<any>("/products/scan", { method: "POST", body: JSON.stringify({ url }) }),
|
||||
},
|
||||
warehouses: {
|
||||
list: () => request<any[]>("/warehouses"),
|
||||
getDefault: () => request<any>("/warehouses/default"),
|
||||
get: (id: string) => request<any>(`/warehouses/${id}`),
|
||||
create: (body: any) => request<any>("/warehouses", { method: "POST", body: JSON.stringify(body) }),
|
||||
update: (id: string, body: any) => request<any>(`/warehouses/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
||||
setDefault: (id: string) => request<any>(`/warehouses/${id}/set-default`, { method: "PATCH" }),
|
||||
remove: (id: string) => request<any>(`/warehouses/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
integrations: {
|
||||
list: () => request<any[]>("/integrations"),
|
||||
status: () => request<any>("/integrations/status"),
|
||||
batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) =>
|
||||
request<any>("/integrations/batch", { method: "PUT", body: JSON.stringify({ items }) }),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user