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:
Lizandro Guarnizo
2026-06-01 16:49:19 -05:00
parent 03b18e7a84
commit b2b292c50a
15 changed files with 822 additions and 18 deletions
+4
View File
@@ -14,6 +14,8 @@ import { AuditLogModule } from "./audit-log/audit-log.module";
import { NotificationsModule } from "./notifications/notifications.module";
import { TariffsModule } from "./tariffs/tariffs.module";
import { ProductsModule } from "./products/products.module";
import { WarehousesModule } from "./warehouses/warehouses.module";
import { IntegrationsModule } from "./integrations/integrations.module";
@Module({
imports: [
@@ -35,6 +37,8 @@ import { ProductsModule } from "./products/products.module";
NotificationsModule,
TariffsModule,
ProductsModule,
WarehousesModule,
IntegrationsModule,
],
})
export class AppModule {}
+2
View File
@@ -5,6 +5,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { WarehousesModule } from "../warehouses/warehouses.module";
@Module({
imports: [
@@ -17,6 +18,7 @@ import { JwtStrategy } from "./jwt.strategy";
signOptions: { expiresIn: config.get("JWT_EXPIRES_IN", "15m") },
}),
}),
WarehousesModule,
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
+19 -11
View File
@@ -4,6 +4,7 @@ import {
import { JwtService } from "@nestjs/jwt";
import { ConfigService } from "@nestjs/config";
import { PrismaService } from "../prisma/prisma.service";
import { WarehousesService } from "../warehouses/warehouses.service";
import { generateSuiteCode } from "../common/utils/suite-code.util";
import { RegisterDto, LoginDto } from "./dto/auth.dto";
import * as bcrypt from "bcrypt";
@@ -13,22 +14,29 @@ import { TOTP, generateSecret, generateURI, verify as totpVerify } from "otplib"
const TENANT_SLUG = "moraworld";
const BCRYPT_ROUNDS = 10;
function buildSuiteAddress(suiteCode: string, config: import("@nestjs/config").ConfigService): string {
const street = config.get("WAREHOUSE_ADDRESS_STREET", "150 N Day St");
const city = config.get("WAREHOUSE_ADDRESS_CITY", "City of Orange");
const state = config.get("WAREHOUSE_ADDRESS_STATE", "NJ");
const zip = config.get("WAREHOUSE_ADDRESS_ZIP", "07050");
return `${street}, Suite ${suiteCode}, ${city}, ${state} ${zip}, EE.UU.`;
}
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwt: JwtService,
private config: ConfigService,
private warehouses: WarehousesService,
) {}
/** Builds the suite address from the default warehouse in DB, falls back to env vars */
private async buildSuiteAddress(suiteCode: string, tenantId: string): Promise<string> {
const wh = await this.warehouses.findDefault(tenantId);
if (wh) {
return `${wh.street}, Suite ${suiteCode}, ${wh.city}, ${wh.state} ${wh.zip}, EE.UU.`;
}
// Fallback to env vars (backwards compat during migration)
const street = this.config.get("WAREHOUSE_ADDRESS_STREET", "150 N Day St");
const city = this.config.get("WAREHOUSE_ADDRESS_CITY", "City of Orange");
const state = this.config.get("WAREHOUSE_ADDRESS_STATE", "NJ");
const zip = this.config.get("WAREHOUSE_ADDRESS_ZIP", "07050");
return `${street}, Suite ${suiteCode}, ${city}, ${state} ${zip}, EE.UU.`;
}
// ─── Register ────────────────────────────────────────────────
async register(dto: RegisterDto): Promise<any> {
const tenant = await this.prisma.client.tenant.findUnique({ where: { slug: TENANT_SLUG } });
@@ -66,7 +74,7 @@ export class AuthService {
return {
user: this.sanitizeUser(user),
suiteCode,
suiteAddress: buildSuiteAddress(suiteCode, this.config),
suiteAddress: await this.buildSuiteAddress(suiteCode, tenant.id),
...tokens,
};
}
@@ -114,7 +122,7 @@ export class AuthService {
user: this.sanitizeUser(user),
suite: suite ? {
code: suite.code,
address: buildSuiteAddress(suite.code, this.config),
address: await this.buildSuiteAddress(suite.code, tenant.id),
} : null,
...tokens,
};
@@ -186,7 +194,7 @@ export class AuthService {
...this.sanitizeUser(user),
suite: suite ? {
code: suite.code,
address: buildSuiteAddress(suite.code, this.config),
address: await this.buildSuiteAddress(suite.code, user.tenantId),
} : null,
};
}
@@ -0,0 +1,29 @@
import { Controller, Get, Put, Body, UseGuards } from "@nestjs/common";
import { IntegrationsService } from "./integrations.service";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("integrations")
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
export class IntegrationsController {
constructor(private svc: IntegrationsService) {}
@Get()
findAll(@CurrentUser() user: any) {
return this.svc.findAll(user.tenantId);
}
@Get("status")
getStatus(@CurrentUser() user: any) {
return this.svc.getStatus(user.tenantId);
}
@Put("batch")
batchUpsert(
@CurrentUser() user: any,
@Body() body: { items: Array<{ key: string; value: string | null; isActive: boolean }> },
) {
return this.svc.batchUpsert(user.tenantId, body.items, user.id);
}
}
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { IntegrationsService } from "./integrations.service";
import { IntegrationsController } from "./integrations.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [IntegrationsService],
controllers: [IntegrationsController],
exports: [IntegrationsService],
})
export class IntegrationsModule {}
@@ -0,0 +1,122 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
/** All integration keys defined in the system, grouped by category */
export const INTEGRATION_CATALOG = [
// ── Pasarela de Pagos ──────────────────────────────────────
{ key: "stripe_public_key", label: "Stripe — Clave pública", group: "payment", required: false },
{ key: "stripe_secret_key", label: "Stripe — Clave secreta", group: "payment", required: false },
{ key: "payphone_token", label: "PayPhone — Token de API", group: "payment", required: false },
{ key: "paypal_client_id", label: "PayPal — Client ID", group: "payment", required: false },
// ── Notificaciones ────────────────────────────────────────
{ key: "whatsapp_token", label: "WhatsApp Business — Token", group: "notifications", required: true },
{ key: "whatsapp_phone_id", label: "WhatsApp Business — Phone ID", group: "notifications", required: true },
{ key: "whatsapp_nj_number", label: "WhatsApp NJ (operaciones)", group: "notifications", required: false },
{ key: "whatsapp_ec_number", label: "WhatsApp Ecuador (aduanas)", group: "notifications", required: false },
{ key: "sendgrid_api_key", label: "SendGrid — API Key", group: "notifications", required: false },
{ key: "email_from", label: "Email remitente (from)", group: "notifications", required: false },
{ key: "sms_provider", label: "SMS — Proveedor (ej: Twilio)", group: "notifications", required: false },
{ key: "sms_api_key", label: "SMS — API Key", group: "notifications", required: false },
// ── Aduana / SENAE ────────────────────────────────────────
{ key: "senae_endpoint", label: "SENAE — URL WebService", group: "customs", required: true },
{ key: "senae_api_key", label: "SENAE — API Key / Token", group: "customs", required: true },
{ key: "senae_ruc", label: "RUC declarante SENAE", group: "customs", required: true },
{ key: "senae_agent_code", label: "Código Agente Aduanero SENAE", group: "customs", required: false },
// ── Couriers ──────────────────────────────────────────────
{ key: "fedex_api_key", label: "FedEx — API Key", group: "courier", required: false },
{ key: "fedex_account", label: "FedEx — Account Number", group: "courier", required: false },
{ key: "dhl_api_key", label: "DHL — API Key", group: "courier", required: false },
{ key: "ups_client_id", label: "UPS — Client ID", group: "courier", required: false },
{ key: "ups_client_secret", label: "UPS — Client Secret", group: "courier", required: false },
// ── Amazon SP-API ─────────────────────────────────────────
{ key: "amazon_client_id", label: "Amazon SP-API — Client ID", group: "marketplace", required: false },
{ key: "amazon_client_secret", label: "Amazon SP-API — Client Secret", group: "marketplace", required: false },
{ key: "amazon_refresh_token", label: "Amazon SP-API — Refresh Token", group: "marketplace", required: false },
// ── INEN ──────────────────────────────────────────────────
{ key: "inen_endpoint", label: "INEN — URL de consulta", group: "compliance", required: false },
{ key: "inen_api_key", label: "INEN — API Key", group: "compliance", required: false },
// ── Bodega propia ─────────────────────────────────────────
{ key: "warehouse_api_url", label: "Software Bodega — URL base", group: "warehouse", required: false },
{ key: "warehouse_api_key", label: "Software Bodega — API Key", group: "warehouse", required: false },
{ key: "warehouse_webhook_secret", label: "Software Bodega — Webhook Secret", group: "warehouse", required: false },
];
@Injectable()
export class IntegrationsService {
constructor(private prisma: PrismaService) {}
/** Return all integration keys for the tenant, merged with the catalog */
async findAll(tenantId: string): Promise<any[]> {
const stored = await this.prisma.client.integration.findMany({
where: { tenantId },
});
const storedMap = new Map(stored.map(s => [s.key, s]));
return INTEGRATION_CATALOG.map(cat => {
const stored = storedMap.get(cat.key);
return {
...cat,
id: stored?.id ?? null,
value: stored?.value ? "***" : null, // mask for security
hasValue: !!stored?.value,
isActive: stored?.isActive ?? false,
updatedAt: stored?.updatedAt ?? null,
};
});
}
/** Upsert a single integration key */
async upsert(tenantId: string, key: string, value: string | null, isActive: boolean, updatedBy: string): Promise<any> {
const cat = INTEGRATION_CATALOG.find(c => c.key === key);
const result = await this.prisma.client.integration.upsert({
where: { tenantId_key: { tenantId, key } },
create: {
tenantId,
key,
value: value ?? null,
label: cat?.label,
group: cat?.group,
isActive,
updatedBy,
},
update: {
value: value !== undefined ? value : undefined,
isActive,
updatedBy,
updatedAt: new Date(),
},
});
return { ...result, value: result.value ? "***" : null, hasValue: !!result.value };
}
/** Batch upsert multiple keys at once */
async batchUpsert(tenantId: string, items: Array<{ key: string; value: string | null; isActive: boolean }>, updatedBy: string): Promise<void> {
await Promise.all(
items.map(item => this.upsert(tenantId, item.key, item.value, item.isActive, updatedBy))
);
}
/** Get a single integration value (unmasked — internal use only) */
async getValue(tenantId: string, key: string): Promise<string | null> {
const row = await this.prisma.client.integration.findUnique({
where: { tenantId_key: { tenantId, key } },
});
return row?.isActive ? (row.value ?? null) : null;
}
/** Return connection status summary by group */
async getStatus(tenantId: string): Promise<Record<string, { connected: boolean; missing: string[] }>> {
const stored = await this.prisma.client.integration.findMany({ where: { tenantId, isActive: true } });
const activeKeys = new Set(stored.filter(s => !!s.value).map(s => s.key));
const groups = [...new Set(INTEGRATION_CATALOG.map(c => c.group ?? "other"))];
const status: Record<string, { connected: boolean; missing: string[] }> = {};
for (const group of groups) {
const required = INTEGRATION_CATALOG.filter(c => c.group === group && c.required);
const missing = required.filter(c => !activeKeys.has(c.key)).map(c => c.label);
status[group] = { connected: missing.length === 0 && required.length > 0, missing };
}
return status;
}
}
@@ -0,0 +1,32 @@
import { IsString, IsOptional, IsBoolean, IsNumber, Min } from "class-validator";
import { Type } from "class-transformer";
export class CreateWarehouseDto {
@IsString() name!: string;
@IsString() street!: string;
@IsString() city!: string;
@IsString() state!: string;
@IsString() zip!: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() contactName?: string;
@IsOptional() @IsString() schedule?: string;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) capacityM2?: number;
@IsOptional() @IsBoolean() isDefault?: boolean;
}
export class UpdateWarehouseDto {
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() street?: string;
@IsOptional() @IsString() city?: string;
@IsOptional() @IsString() state?: string;
@IsOptional() @IsString() zip?: string;
@IsOptional() @IsString() country?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() email?: string;
@IsOptional() @IsString() contactName?: string;
@IsOptional() @IsString() schedule?: string;
@IsOptional() @Type(() => Number) @IsNumber() @Min(0) capacityM2?: number;
@IsOptional() @IsBoolean() isActive?: boolean;
}
@@ -0,0 +1,53 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from "@nestjs/common";
import { WarehousesService } from "./warehouses.service";
import { CreateWarehouseDto, UpdateWarehouseDto } from "./dto/warehouse.dto";
import { JwtAuthGuard, RolesGuard, Roles } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
@Controller("warehouses")
@UseGuards(JwtAuthGuard, RolesGuard)
export class WarehousesController {
constructor(private svc: WarehousesService) {}
@Get()
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO")
findAll(@CurrentUser() user: any) {
return this.svc.findAll(user.tenantId);
}
@Get("default")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA", "AGENTE_ADUANERO", "CLIENTE")
findDefault(@CurrentUser() user: any) {
return this.svc.findDefault(user.tenantId);
}
@Get(":id")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN", "OPERADOR_BODEGA")
findOne(@Param("id") id: string, @CurrentUser() user: any) {
return this.svc.findOne(id, user.tenantId);
}
@Post()
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
create(@Body() dto: CreateWarehouseDto, @CurrentUser() user: any) {
return this.svc.create(dto, user.tenantId);
}
@Patch(":id")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
update(@Param("id") id: string, @Body() dto: UpdateWarehouseDto, @CurrentUser() user: any) {
return this.svc.update(id, dto, user.tenantId);
}
@Patch(":id/set-default")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
setDefault(@Param("id") id: string, @CurrentUser() user: any) {
return this.svc.setDefault(id, user.tenantId);
}
@Delete(":id")
@Roles("ADMIN_EMPRESA", "SUPER_ADMIN")
remove(@Param("id") id: string, @CurrentUser() user: any) {
return this.svc.remove(id, user.tenantId);
}
}
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { WarehousesService } from "./warehouses.service";
import { WarehousesController } from "./warehouses.controller";
import { PrismaModule } from "../prisma/prisma.module";
@Module({
imports: [PrismaModule],
providers: [WarehousesService],
controllers: [WarehousesController],
exports: [WarehousesService],
})
export class WarehousesModule {}
@@ -0,0 +1,90 @@
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { CreateWarehouseDto, UpdateWarehouseDto } from "./dto/warehouse.dto";
@Injectable()
export class WarehousesService {
constructor(private prisma: PrismaService) {}
async findAll(tenantId: string): Promise<any[]> {
return this.prisma.client.warehouse.findMany({
where: { tenantId },
orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }],
});
}
async findDefault(tenantId: string): Promise<any | null> {
return this.prisma.client.warehouse.findFirst({
where: { tenantId, isDefault: true, isActive: true },
});
}
async findOne(id: string, tenantId: string): Promise<any> {
const wh = await this.prisma.client.warehouse.findFirst({ where: { id, tenantId } });
if (!wh) throw new NotFoundException("Bodega no encontrada.");
return wh;
}
async create(dto: CreateWarehouseDto, tenantId: string): Promise<any> {
// If this is the first warehouse or explicitly set as default, handle default logic
const existing = await this.prisma.client.warehouse.count({ where: { tenantId } });
const setAsDefault = dto.isDefault ?? existing === 0;
if (setAsDefault) {
await this.prisma.client.warehouse.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
});
}
return this.prisma.client.warehouse.create({
data: {
tenantId,
name: dto.name,
street: dto.street,
city: dto.city,
state: dto.state,
zip: dto.zip,
country: dto.country ?? "US",
phone: dto.phone,
email: dto.email,
contactName: dto.contactName,
schedule: dto.schedule,
capacityM2: dto.capacityM2,
isDefault: setAsDefault,
},
});
}
async update(id: string, dto: UpdateWarehouseDto, tenantId: string): Promise<any> {
await this.findOne(id, tenantId);
return this.prisma.client.warehouse.update({
where: { id },
data: dto,
});
}
async setDefault(id: string, tenantId: string): Promise<any> {
await this.findOne(id, tenantId);
// Remove default from all, set on this one
await this.prisma.client.warehouse.updateMany({
where: { tenantId, isDefault: true },
data: { isDefault: false },
});
return this.prisma.client.warehouse.update({
where: { id },
data: { isDefault: true, isActive: true },
});
}
async remove(id: string, tenantId: string): Promise<void> {
const wh = await this.findOne(id, tenantId);
if (wh.isDefault) throw new BadRequestException("No puedes eliminar la bodega predeterminada.");
await this.prisma.client.warehouse.delete({ where: { id } });
}
/** Build the suite address string from the default warehouse */
buildSuiteAddress(warehouse: any, suiteCode: string): string {
return `${warehouse.street}, Suite ${suiteCode}, ${warehouse.city}, ${warehouse.state} ${warehouse.zip}, EE.UU.`;
}
}
@@ -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 && <> &bull; {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>
);
}
+7 -6
View File
@@ -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 }) {
+15
View File
@@ -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 }) }),
},
};
+57 -1
View File
@@ -1,4 +1,4 @@
// Moraworld Imports — Schema v0.2 (Fase 01)
// Moraworld Imports — Schema v0.3 (Fase 2 — Bodegas + Integraciones)
// Multi-tenant por tenant_id en todas las tablas de negocio
// Sincronizado con documentacion.html v1.1
@@ -90,6 +90,8 @@ model Tenant {
preAlerts PreAlert[]
tariffs Tariff[]
b2bRequests B2BRequest[]
warehouses Warehouse[]
integrations Integration[]
}
// ─── Usuarios ────────────────────────────────────────────────
@@ -344,3 +346,57 @@ model AuditLog {
@@index([tenantId, createdAt])
@@index([userId])
}
// ─── Bodegas (multi-bodega, doc §07 + §12) ───────────────────
/// Bodega física. Cada tenant puede tener varias; una es la default.
model Warehouse {
id String @id @default(cuid())
tenantId String
name String // ej: "Bodega NJ — City of Orange"
street String // ej: "150 N Day St"
city String // ej: "City of Orange"
state String // ej: "NJ"
zip String // ej: "07050"
country String @default("US")
phone String?
email String?
contactName String?
/// Horario de operación (texto libre)
schedule String?
/// Capacidad aproximada (m²)
capacityM2 Decimal? @db.Decimal(10, 2)
isDefault Boolean @default(false)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@index([tenantId])
}
// ─── Integraciones (API keys por tenant) ─────────────────────
/// Configuración de integraciones externas (API keys, endpoints, webhooks).
/// El valor se almacena en texto plano en dev; en prod debe cifrarse (AES-256).
model Integration {
id String @id @default(cuid())
tenantId String
/// Clave identificadora: ej. "whatsapp_token", "senae_endpoint", "stripe_secret"
key String
/// Valor (API key, URL, token — cifrar en prod)
value String?
/// Descripción legible del campo
label String?
/// Grupo de integración: "payment", "notifications", "customs", "courier", "warehouse"
group String?
isActive Boolean @default(false)
updatedAt DateTime @updatedAt
updatedBy String?
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@unique([tenantId, key])
@@index([tenantId, group])
}
+24
View File
@@ -50,6 +50,30 @@ async function main() {
});
console.log("✓ Tarifa base configurada ($3.50/lb, 2% seguro, 0.5% FODINFA, 15% IVA)");
// ─── Bodega predeterminada (doc §07 + §12) ────────────────
const warehouseCount = await prisma.warehouse.count({ where: { tenantId: tenant.id } });
if (warehouseCount === 0) {
await prisma.warehouse.create({
data: {
tenantId: tenant.id,
name: "Bodega NJ — City of Orange",
street: process.env.WAREHOUSE_ADDRESS_STREET ?? "150 N Day St",
city: process.env.WAREHOUSE_ADDRESS_CITY ?? "City of Orange",
state: process.env.WAREHOUSE_ADDRESS_STATE ?? "NJ",
zip: process.env.WAREHOUSE_ADDRESS_ZIP ?? "07050",
country: process.env.WAREHOUSE_ADDRESS_COUNTRY ?? "US",
phone: "+1-555-0100",
contactName: "Operaciones Moraworld NJ",
schedule: "LunVie 8:0017:00 ET",
isDefault: true,
isActive: true,
},
});
console.log("✓ Bodega predeterminada creada: 150 N Day St, City of Orange, NJ 07050");
} else {
console.log("→ Bodega predeterminada ya existe");
}
// ─── Usuarios de prueba (uno por cada rol del §06) ────────
const usersToSeed: Array<{