Imported from JuanMolinaNavarro/centralsm (
AGENTS.md). Install upstream withnpx skills add JuanMolinaNavarro/centralsm. Copyright stays with the author.
This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ before writing any code. Heed deprecation notices.
CentralSM
Plataforma interna para centralizar el stock management de la empresa (ISP), integrada con la API de Teamplace / Finnegans (docs/API-Teamplace-Finnegans.md). Todo el código, UI y comentarios están en español.
Stack
- Next.js 16 (App Router, TypeScript, Turbopack,
output: "standalone") - shadcn/ui sobre Base UI (
@base-ui/react, no Radix) + Tailwind v4 - PostgreSQL 17 · Prisma 7 con adapter
@prisma/adapter-pg(cliente generado ensrc/generated/prisma) - Docker Compose para dev (app en host
:3100, Postgres en:5433) y producción (docker-compose.prod.yml)
Módulos (rutas en src/app/)
/dashboard— KPIs de la última sincronización, productos nuevos, cambios de stock e historial de corridas. Acepta?run=<syncRunId>para ver el estado de una corrida anterior (vista histórica: KPIs desdeSyncRun, cambios desdeHistorialStock, niveles desdeSnapshotStock)./dashboard/movimientos— histórico de entradas/salidas por producto y depósito (?dias=7|30|90)./catalogo— módulo unificado con tabs (CatalogoTabs):- Categorías (
/catalogo,/catalogo/[id],/catalogo/articulo/[id]) — árbol de categorías con SKU por capas (#IR-1-ADS-0001). - Clasificar (
/catalogo/clasificar) — mesa de trabajo para catalogar a mano: lista paginada server-side (?q=&cat=pendientes|todas|<categoriaId>&stock=1&orden=nombre|stock|reciente&pagina=), selección múltiple (clic en fila / Shift+clic / toda la página) y «Mover a…» conCategoriaPicker(búsqueda por ruta+SKU, recientes en localStorage, crear subcategoría inline). Por defecto muestra el subárbol#REV(lo que el sync no supo clasificar). AcciónmoverProductos(ids, categoriaId)encatalogo/actions.ts(regenerasecuencia+codigoSku, dos pasadas con SKU temporal) ydeshacerMovidapara el toast de deshacer. El mismoMoverArticulosDialogse usa en la ficha del artículo y en el hover deArticuloCard. Datos ensrc/lib/catalogo.ts(getCategoriasPlanas,buscarArticulosParaClasificar,contarPendientes); tipos/constantes compartidos cliente-servidor ensrc/lib/catalogo-tipos.ts(no importarlib/catalogo.tsdesde componentes cliente: arrastra Prisma). - Buscador (
/catalogo?q=&pagina=) — searchbar arriba del árbol: mientras hay texto, los resultados (grilla deArticuloCardcon la ruta de su categoría) reemplazan a las macro categorías. Busca en nombre, SKU, código Teamplace y características (valor, valor+unidad del tipo, y nombre del tipo). Todo se compara normalizado:normalizarBusqueda()ensrc/lib/busqueda.tsy su gemelo en SQLcentralsm_norm(text)(creado en la migración..._caracteristicas_producto,IMMUTABLE+ índices GINpg_trgm) — si cambia una, cambiar la otra. Dos pasadas enbuscarIdsPorTexto(): primero la consulta entera pegada como frase ("3 W"→3w, que es lo que se quiere decir al escribir una medida) y, si no da nada, el AND palabra por palabra ("antena 3w"). El clasificador usa la misma función (entra comowhere.id = { in: ids }, sin tocar sus otros filtros). Tope deLIMITE_BUSQUEDAcoincidencias; la UI avisa cuando trunca. El debounce + sync con la URL vive en el hookuseBusquedaUrl(compartido porBuscadorCatalogoyClasificador). Las cards (ArticuloCard, en el buscador y en la vista de categoría) muestran hasta 3 características con valor como badges + «+N» con el resto en tooltip; el buscador las adjunta congetCaracteristicasCardPorProducto()sobre la página de resultados ygetCategoria()las incluye en sus productos — el clasificador no las carga. - Características de artículo (por familia) — pares tipo → valor, en la ficha de
/catalogo/articulo/[id]. Los tipos (TipoCaracteristica: nombre,nombreClavenormalizado y único, unidad opcional,tipoValoryopciones) los crea el usuario y son transversales a todo el catálogo; el valor (CaracteristicaProducto, único por(productoId, tipoId)) se guarda como texto. La familia es la categoría del artículo:guardarCaracteristicaasocia el tipo aCaracteristicaFamilia(único por(categoriaId, tipoId)) además de guardar el valor, así todos los hermanos ven el tipo con valor opcional (fila vacía si no cargaron nada). Valor vacío = borra el valor pero deja el tipo en la familia;quitarTipoDeFamilia(categoriaId, tipoId)es destructivo (borra la asociación y los valores de todos los artículos de esa categoría; la UI confirma mostrandovaloresEnFamilia). Al mover un artículo de categoría los tipos NO viajan: sus valores quedan como «fuera de familia» en la ficha (guardarles un valor los incorpora a la familia nueva). Sin backfill: la tabla se puebla con el uso. UI enCaracteristicasArticulo(cada fila maneja sus propias acciones con un solouseTransition; el tacho y las flechas usanonPointerDown preventDefaultpara no robar el foco del input — sin eso el blur disparaba un upsert en carrera con el delete y "resucitaba" filas borradas) +TipoCaracteristicaPicker(sin caché entre aperturas; ahí se crean, renombran y eliminan los tipos). Acciones encatalogo/actions.ts; lecturasgetCaracteristicasFicha(familia ⟕ valores + huérfanas) /getCaracteristicasCardPorProducto/getTiposCaracteristicaensrc/lib/catalogo.ts; tipos cliente-safe ensrc/lib/caracteristicas-tipos.ts.- Tipo de valor (
TipoValorCaracteristica:TEXTO|NUMERO|SELECTOR|BOOLEANO, se elige al crear/editar el tipo).normalizarValorCaracteristica()(cliente-safe, la usaguardarCaracteristicaen el server) valida y canoniza: número con punto decimal ("3,5"→"3.5"; la UI lo muestra es-AR), opción exacta deopciones(comparación sin mayúsculas),"Sí"/"No"para booleanos. Cambiar el tipo u opciones NO convierte los valores viejos:valorRespetaTipo()los marca «Fuera de regla» en la ficha hasta corregirlos. Editor por tipo enEditorValorCaracteristica(input con la unidad como sufijo fijo,Selectde shadcn/Base UI para selectores —muestra el valor viejo deshabilitado si quedó fuera de las opciones—, botones Sí/No). - Unidad: el valor se guarda SIN unidad y la UI la agrega sola a la derecha (
formatearValorCaracteristica/textoCaracteristica). Si el usuario la escribe pegada («3 W» con unidad «W»),quitarUnidadPegada()la recorta antes de guardar; la migración..._tipo_valor_caracteristicahizo el mismo recorte sobre los valores existentes. El buscador sigue matcheandovalor || unidad(«3w»). - Orden de importancia:
CaracteristicaFamilia.orden(por categoría) manda en la ficha, en las cards y en la descripción de los grupos; flechas ↑↓ en la ficha →moverTipoEnFamilia(categoriaId, tipoId, "arriba"|"abajo")(renumera 1..n). Las huérfanas van al final porTipoCaracteristica.orden.
- Tipo de valor (
- Grupos de artículos equivalentes ("productos") — dos artículos de la misma categoría con exactamente las mismas características (mismo set tipo→valor, comparado con
normalizarBusqueda) son el mismo producto de distinto proveedor. Es derivado, no existe en la DB:firmaCaracteristicas()(sha1 corto del set) yagruparPorCaracteristicas()ensrc/lib/catalogo.ts;getCategoria()devuelvegrupos+sueltos, y la vista de categoría muestra cada grupo comoGrupoProductoCard(subcategoría virtual) en lugar de sus artículos. Página del grupo en/catalogo/[id]/grupo/[firma](getGrupoDeCategoria): comparativa + secciones por proveedor (proveedoresDeArticulo():ProveedorArticulode la ficha operativa → si no hay,finnegansMarca→ «Sin proveedor cargado»; un artículo con varios proveedores aparece en cada sección). La ficha del artículo lista sus equivalentes (getEquivalentesDeProducto). Como la firma depende de los valores, editar una característica rearma los grupos y puede invalidar la URL. Tipos ensrc/lib/catalogo-tipos.ts(GrupoProducto,ArticuloDeGrupo). - Verificación de categorías — flag manual
Categoria.verificadaAt/verificadaPor(texto libre hasta que haya usuarios). Estado derivado engetVerificacionPorCategoria()(src/lib/catalogo.ts, una query raw):verificada, ocon_cambiossi algúnProducto.clasificadoAt(se setea al crear y enmoverProductos) oCategoria.createdAtde un hijo es posterior averificadaAt. AccionesverificarCategoria(id, nombre)/quitarVerificacionCategoria(id); UI enVerificacionBadge(cards, header de categoría, picker) yVerificarCategoriaButton(marcar / re-verificar + «Ver cambios» / quitar). Tipos ensrc/lib/verificacion-tipos.ts. - Documentación por artículo — PDF o Word (.doc/.docx, máx. 25 MB) adjuntos en la ficha de
/catalogo/articulo/[id](DocumentosArticulo): manuales, hojas de datos, certificados. ModeloDocumentoArticulo(nombre original,archivofísico único, mime, bytes, descripción opcional; cascada al borrar el artículo). El archivo va al mismoUPLOADS_DIRque las imágenes (volumen persistente + backup diario). Subida por route handlerPOST /api/documentos(multipart:file,productoId,descripcion) y no por server action (tope de body de 1 MB);saveDocumentoensrc/lib/uploads.tstoma la extensión del nombre porque el MIME de .doc/.docx del navegador no es confiable. Se sirve porGET /api/documentos/[id]conContent-Dispositioninline (los PDF abren en pestaña) o?descargar=1(attachment), siempre con el nombre original. AccionesactualizarDocumentoArticulo(id, descripcion)/eliminarDocumentoArticulo(id)(borra fila + archivo);eliminarProductoyeliminarCategoriatambién limpian los archivos del disco. - Altas Finnegans (
/catalogo/altas,/catalogo/altas/nuevo) — altas locales que un bot Playwright (worker separado) carga en Finnegans Go; estado de push por producto. - Ficha operativa (
/catalogo/articulo/[id]/ficha) — investigación operativa por artículo, tres pestañas: maestro (segmentación, proveedores con precios, lead times), movimientos clasificados por tipo (solo CONSUMO es demanda) y derivado (ADI/CV² → patrón Syntetos-Boylan → política de compra, stock de seguridad Z=1,65, punto de pedido, sugerencia q, ABC, kit). Motor puro ensrc/lib/ficha.ts(verificado contra el prototipo), datos ensrc/lib/ficha-data.ts, UI ensrc/components/ficha/.
- Categorías (
/depositos— existencias por depósito./login— auth simple (src/lib/auth.ts, proxy ensrc/proxy.ts).
No existe más el módulo Teamplace en el frontend (se eliminó por redundante); next.config.ts redirige /productos* y /teamplace a sus reemplazos. La integración Teamplace vive solo en backend: src/lib/teamplace.ts (cliente API), src/lib/teamplace-jobs.ts (sync diario) y scripts/teamplace-*.ts.
Arquitectura de datos (ver prisma/schema.prisma)
Categoria(árbol auto-referenciado, cada capa aporta un segmento del SKU) →Producto(hoja con correlativo).CaracteristicaFamiliaasocia tipos de característica a una categoría (ver bullet de características).CategoriaEliminada— lápidas porcodigoSku:eliminarCategoriaregistra el subárbol entero y el sync no vuelve a sembrar esas categorías de la taxonomía (antes el upsert nocturno las resucitaba vacías). El sync tampoco pisanombre/descripcionde categorías existentes (la curaduría manual manda),#REVse asegura siempre, y un producto nuevo cuya categoría destino fue eliminada cae en#REV. Crear a mano una categoría con el mismo SKU levanta la lápida.- Sync diario (cron 2 AM,
scripts/cron.ts→teamplace-jobs.ts): cada corrida crea unSyncRun(conejecutadoAtal final de la corrida) +HistorialStock(deltas por producto/depósito) +SnapshotStock(niveles absolutos completos del día). - Altas hacia Finnegans:
FinnegansPushJobcomo cola en DB; el worker (worker/, contenedor propio) la procesa con Playwright. La UI hace polling vía/api/finnegans-push/[jobId].
Convenciones
- Botones/links de shadcn sobre Base UI:
render={<Link href=... />}+nativeButton={false}(noasChild). - Páginas con datos:
export const dynamic = "force-dynamic"ysearchParams/paramsson Promise (hay queawait). - Server actions en
actions.tsjunto a la ruta; devuelven{ ok, ... } | { ok: false, error }. - Fechas con
fechaHoraAR(src/lib/fecha.ts); números contoLocaleString("es-AR").
Comandos
docker compose up -d --build # dev: app :3100 + Postgres :5433 (hot reload por polling)
npm run build # build de producción (typecheck incluido)
npm run lint # eslint
npm run db:migrate # prisma migrate dev
npm run db:generate # regenerar cliente Prisma (src/generated/prisma)
npm run sync:daily # corrida manual del sync
npx tsx scripts/importar-kardex.ts scripts/kardex-24m.jsonl --apply # recargar movimientos del kardex (fuente=KARDEX)
npx tsx scripts/verificar-ficha.ts [codigos] # chequeo rápido del derivado post-import
npm run teamplace:ping # probar credenciales de la API Teamplace
npm run worker # worker de altas Finnegans (normalmente en su contenedor)
Si npx tsc --noEmit falla con módulos inexistentes bajo .next/, son tipos generados viejos: borrar .next y usar npm run build.
Producción: servidor LAN 192.168.100.108:3100 — deploy con scripts/deploy-lan.sh sobre docker-compose.prod.yml (ver docs/).