From b2b292c50a95de893641ac66837c89729a6ad301 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:49:19 -0500 Subject: [PATCH] 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 --- apps/api/src/app.module.ts | 4 + apps/api/src/auth/auth.module.ts | 2 + apps/api/src/auth/auth.service.ts | 30 +- .../integrations/integrations.controller.ts | 29 ++ .../src/integrations/integrations.module.ts | 12 + .../src/integrations/integrations.service.ts | 122 +++++++ apps/api/src/warehouses/dto/warehouse.dto.ts | 32 ++ .../src/warehouses/warehouses.controller.ts | 53 +++ apps/api/src/warehouses/warehouses.module.ts | 12 + apps/api/src/warehouses/warehouses.service.ts | 90 +++++ apps/web/src/app/admin/configuracion/page.tsx | 344 ++++++++++++++++++ apps/web/src/app/admin/layout.tsx | 13 +- apps/web/src/lib/api.ts | 15 + packages/database/prisma/schema.prisma | 58 ++- packages/database/prisma/seed.ts | 24 ++ 15 files changed, 822 insertions(+), 18 deletions(-) create mode 100644 apps/api/src/integrations/integrations.controller.ts create mode 100644 apps/api/src/integrations/integrations.module.ts create mode 100644 apps/api/src/integrations/integrations.service.ts create mode 100644 apps/api/src/warehouses/dto/warehouse.dto.ts create mode 100644 apps/api/src/warehouses/warehouses.controller.ts create mode 100644 apps/api/src/warehouses/warehouses.module.ts create mode 100644 apps/api/src/warehouses/warehouses.service.ts create mode 100644 apps/web/src/app/admin/configuracion/page.tsx diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ba92ab7..ec45f6b 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index fd02a25..4844e9d 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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], diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 84a2372..c7a282d 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { 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, }; } diff --git a/apps/api/src/integrations/integrations.controller.ts b/apps/api/src/integrations/integrations.controller.ts new file mode 100644 index 0000000..12afb36 --- /dev/null +++ b/apps/api/src/integrations/integrations.controller.ts @@ -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); + } +} diff --git a/apps/api/src/integrations/integrations.module.ts b/apps/api/src/integrations/integrations.module.ts new file mode 100644 index 0000000..61b8d9f --- /dev/null +++ b/apps/api/src/integrations/integrations.module.ts @@ -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 {} diff --git a/apps/api/src/integrations/integrations.service.ts b/apps/api/src/integrations/integrations.service.ts new file mode 100644 index 0000000..5ca851f --- /dev/null +++ b/apps/api/src/integrations/integrations.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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> { + 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 = {}; + + 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; + } +} diff --git a/apps/api/src/warehouses/dto/warehouse.dto.ts b/apps/api/src/warehouses/dto/warehouse.dto.ts new file mode 100644 index 0000000..1f4ce57 --- /dev/null +++ b/apps/api/src/warehouses/dto/warehouse.dto.ts @@ -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; +} diff --git a/apps/api/src/warehouses/warehouses.controller.ts b/apps/api/src/warehouses/warehouses.controller.ts new file mode 100644 index 0000000..52f1e35 --- /dev/null +++ b/apps/api/src/warehouses/warehouses.controller.ts @@ -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); + } +} diff --git a/apps/api/src/warehouses/warehouses.module.ts b/apps/api/src/warehouses/warehouses.module.ts new file mode 100644 index 0000000..9ae7fc7 --- /dev/null +++ b/apps/api/src/warehouses/warehouses.module.ts @@ -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 {} diff --git a/apps/api/src/warehouses/warehouses.service.ts b/apps/api/src/warehouses/warehouses.service.ts new file mode 100644 index 0000000..0fe8f8f --- /dev/null +++ b/apps/api/src/warehouses/warehouses.service.ts @@ -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 { + return this.prisma.client.warehouse.findMany({ + where: { tenantId }, + orderBy: [{ isDefault: "desc" }, { createdAt: "asc" }], + }); + } + + async findDefault(tenantId: string): Promise { + return this.prisma.client.warehouse.findFirst({ + where: { tenantId, isDefault: true, isActive: true }, + }); + } + + async findOne(id: string, tenantId: string): Promise { + 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 { + // 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 { + await this.findOne(id, tenantId); + return this.prisma.client.warehouse.update({ + where: { id }, + data: dto, + }); + } + + async setDefault(id: string, tenantId: string): Promise { + 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 { + 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.`; + } +} diff --git a/apps/web/src/app/admin/configuracion/page.tsx b/apps/web/src/app/admin/configuracion/page.tsx new file mode 100644 index 0000000..ed87b3c --- /dev/null +++ b/apps/web/src/app/admin/configuracion/page.tsx @@ -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("bodegas"); + + // ── Bodegas state ── + const [warehouses, setWarehouses] = useState([]); + const [whLoading, setWhLoading] = useState(true); + const [whError, setWhError] = useState(null); + const [editing, setEditing] = useState(null); // null = closed, {} = create, {id,..} = edit + const [whForm, setWhForm] = useState(EMPTY_WH); + const [whSaving, setWhSaving] = useState(false); + + // ── Integrations state ── + const [integrations, setIntegrations] = useState([]); + const [intLoading, setIntLoading] = useState(false); + const [intSaving, setIntSaving] = useState(false); + const [intEdits, setIntEdits] = useState>({}); // key → raw value + const [intActive, setIntActive] = useState>({}); // key → isActive + const [intSuccess, setIntSuccess] = useState(false); + + // ── API status state ── + const [statusData, setStatusData] = useState(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 = {}; + const active: Record = {}; + 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 ( +
+
+

Configuración

+

Bodegas, integraciones externas y estado de APIs

+
+ + {/* Tab bar */} +
+ {([ ["bodegas","🏭","Bodegas"], ["integraciones","🔌","Integraciones"], ["estado","📡","Estado APIs"] ] as const).map(([key, icon, label]) => ( + + ))} +
+ + {/* ── TAB: BODEGAS ─────────────────────────────────────────────────── */} + {tab === "bodegas" && ( +
+
+

Bodegas registradas

+ +
+ + {whLoading &&
} + {whError &&
{whError}
} + + {!whLoading && warehouses.length === 0 && ( +
+

No hay bodegas registradas.

+ +
+ )} + +
+ {warehouses.map(wh => ( +
+
+
+ {wh.name} + {wh.isDefault && Predeterminada} + {!wh.isActive && Inactiva} +
+
+ {wh.street}, {wh.city}, {wh.state} {wh.zip}, {wh.country} + {wh.phone && <> • {wh.phone}} + {wh.schedule && <>
{wh.schedule}} +
+
+
+ {!wh.isDefault && ( + + )} + + {!wh.isDefault && ( + + )} +
+
+ ))} +
+ + {/* ── Warehouse form modal ── */} + {editing !== null && ( +
+
e.stopPropagation()} style={{ maxWidth: 560 }}> +
+

{editing?.id ? "Editar bodega" : "Nueva bodega"}

+ +
+
+ {([ + ["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]) => ( +
+ + setWhForm(f => ({ ...f, [key]: e.target.value }))} /> +
+ ))} +
+
+ + +
+
+
+ )} +
+ )} + + {/* ── TAB: INTEGRACIONES ───────────────────────────────────────────── */} + {tab === "integraciones" && ( +
+
+

+ Los valores ingresados reemplazan las claves actuales. Deja en blanco para no modificar. +

+ {intSuccess && Guardado correctamente} + +
+ + {intLoading &&
} + + {groupedIntegrations.map(group => ( +
+

{group.icon} {group.label}

+
+ {group.items.map((item: any) => ( +
+
+ + {item.required && *} + +
+
+ setIntEdits(d => ({ ...d, [item.key]: e.target.value }))} + /> +
+
+ ))} +
+
+ ))} + + {!intLoading && groupedIntegrations.length === 0 && ( +
No hay integraciones definidas.
+ )} +
+ )} + + {/* ── TAB: ESTADO APIs ─────────────────────────────────────────────── */} + {tab === "estado" && ( +
+
+

Estado de integraciones

+ +
+ + {statusLoading &&
} + + {statusData && ( +
+ {GROUPS.filter(g => statusData[g.key]).map(g => { + const s = statusData[g.key]; + return ( +
+
+ {g.icon} + {g.label} + + {s.connected ? "Conectado" : "Incompleto"} + +
+ {!s.connected && s.missing.length > 0 && ( +
    + {s.missing.map((m: string) =>
  • {m}
  • )} +
+ )} + {s.connected && ( +

Todos los campos requeridos configurados.

+ )} +
+ ); + })} +
+ )} + + {!statusLoading && !statusData && ( +
+

Carga el estado de las APIs.

+ +
+ )} +
+ )} +
+ ); +} diff --git a/apps/web/src/app/admin/layout.tsx b/apps/web/src/app/admin/layout.tsx index 90704ef..c292e6f 100644 --- a/apps/web/src/app/admin/layout.tsx +++ b/apps/web/src/app/admin/layout.tsx @@ -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 }) { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 405d218..fd76102 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -139,4 +139,19 @@ export const api = { products: { scan: (url: string) => request("/products/scan", { method: "POST", body: JSON.stringify({ url }) }), }, + warehouses: { + list: () => request("/warehouses"), + getDefault: () => request("/warehouses/default"), + get: (id: string) => request(`/warehouses/${id}`), + create: (body: any) => request("/warehouses", { method: "POST", body: JSON.stringify(body) }), + update: (id: string, body: any) => request(`/warehouses/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + setDefault: (id: string) => request(`/warehouses/${id}/set-default`, { method: "PATCH" }), + remove: (id: string) => request(`/warehouses/${id}`, { method: "DELETE" }), + }, + integrations: { + list: () => request("/integrations"), + status: () => request("/integrations/status"), + batchUpsert: (items: Array<{ key: string; value: string | null; isActive: boolean }>) => + request("/integrations/batch", { method: "PUT", body: JSON.stringify({ items }) }), + }, }; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 4c135cf..dc343a1 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -1,4 +1,4 @@ -// Moraworld Imports — Schema v0.2 (Fase 0–1) +// 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]) +} diff --git a/packages/database/prisma/seed.ts b/packages/database/prisma/seed.ts index cac3f40..8222800 100644 --- a/packages/database/prisma/seed.ts +++ b/packages/database/prisma/seed.ts @@ -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: "Lun–Vie 8:00–17: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<{