diff --git a/API.md b/API.md index f1746fd..e31daa2 100644 --- a/API.md +++ b/API.md @@ -230,7 +230,10 @@ se registra en consola de desarrollo. Antes de usar estas rutas se debe aplicar - `GET /api/devices?q=&kind=&status=&stale=1&diskFull=1` · `GET /api/devices/:id` (con hardware, red, software e historial) · `PUT /api/devices/:id` — `{ status, assignedUserId, departmentId, notes, editVersion }`. - `GET /api/devices/dashboard` — KPIs y top (SO, tipo, software). `GET /api/devices/software` · - `GET /api/devices/software/devices?name=` — software transversal. + `GET /api/devices/software/devices?name=` — software transversal. Cada programa trae `license_type` + (`free`/`paid`/`personal_free`/`component`/`null`), `license_manual`, `vendor` y `group`. +- `PUT /api/devices/software/classification` — `{ name, licenseType }`: corrige el tipo de un programa + en todos los equipos; `licenseType` vacío vuelve a las reglas. Roles que editan equipos; audita `classify_software`. - `GET /api/devices/access` — `{canView, canEdit, canManage}` del usuario (lo usa el menú). Lectura (`GET` de dashboard/listado/detalle/software) también para usuarios con acceso compartido. - `GET/POST /api/devices/viewers` · `DELETE /api/devices/viewers/:userId` — compartir Equipos (admin/superadmin). diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index b60fe67..7b2a848 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -83,6 +83,7 @@ desde la UI. No hay migración aplicada ni conexión a BD en este cambio. | `devices` | inventario de equipos (menú "Equipos"), upsert por `deviceid` del agente GLPI; baja lógica con `status` | | `device_cpus` / `device_memories` / `device_disks` / `device_volumes` / `device_networks` / `device_monitors` / `device_software` | componentes de cada equipo, reemplazados completos en cada reporte | | `device_accounts` | cuentas que reporta el agente: sesión iniciada, usuarios locales y grupos locales (`kind`), reemplazadas en cada reporte (migración `027`) | +| `software_classifications` | tipo de licencia corregido a mano por programa (`name`, `license_type`, `updated_by`); gana sobre las reglas (migración `040`) | | `device_changes` | diff entre reportes (RAM, discos, software instalado/desinstalado) | | `system_settings` (IA) | `ai_api_key_enc` (AES-256-GCM), `ai_model`, `ai_edit_version` (migración 031) | | `ai_usage` | consumo por llamada a la IA (`user_id`, `action`, `model`, `input_tokens`, `output_tokens`, `cost_usd` estimado, `created_at`) (migración 032) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a6ba16..8d30dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog - project.linkedcorp.net +## [4.16.0] - 2026-10-30 +### Added +- Equipos → Software (detalle del equipo y pestaña general): columna "Tipo" y filtro con Gratuito, + Pago, Gratis solo personal, Componente o incluido y Sin clasificar. Clasificador por reglas de + nombre y fabricante, sin IA ni consultas externas (`softwareClassifier.js`). Es orientativo y no + detecta software sin licencia. Quien edita equipos puede corregir el tipo de un programa desde + el menú de la fila; vale para todos los equipos (migración `040`). +- Software agrupado por fabricante desde 2 programas, con fila desplegable (`ListView` admite + `getChildren`). Windows, Office y otros productos Microsoft con licencia quedan fuera del grupo. + El grupo cerrado muestra el tipo de mayor riesgo y la búsqueda abre los que coinciden. +### Changed +- Las apps de la tienda de Microsoft y los paquetes que vienen con Windows ahora cuentan como + componentes: se ocultan con el selector apagado. +### Fixed +- Los menús `Flyout` quedaban detrás de los popups y diálogos. + ## [4.15.1] - 2026-10-30 ### Fixed - La 4.15.0 se publicó con `VERSION` en 4.15.0 pero los `package.json` en 4.14.0: el frontend se diff --git a/ESTADO.md b/ESTADO.md index ff0463c..f7b9be5 100644 --- a/ESTADO.md +++ b/ESTADO.md @@ -6,6 +6,10 @@ autorregistro/aprobacion/SMTP. ## Conexiones externas (v4.12.0, en dev, sin desplegar) +- v4.16.0 (2026-10-30): Software con tipo de licencia, grupos por fabricante y corrección manual; + migración `040` aplicada en dev. Backend probado contra la BD de dev; pantalla probada con API + simulada (sin login real) y **falta probarla con un usuario real**. Pendiente: servidor LinkedCorp + `.3` sigue en 2.19.0 (decidir si se sube) y aplicar el actualizador en Exilaser. - Conector FoxPro **1.1.0 instalado en Exilaser** (`C:\api_project\foxpro`, puerto 8455, tarea `LinkedCorp-Conector-FoxPro`). Token en `~/.config/linkedcorp/conector-foxpro-exilaser.token`. - **Módulo del sistema hecho en dev**: migración 038 aplicada en dev, `modules/integrations`, job diff --git a/VERSION b/VERSION index fb05571..ecbc3b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.15.1 +4.16.0 diff --git a/backend/database/migrations/040_software_classifications.sql b/backend/database/migrations/040_software_classifications.sql new file mode 100644 index 0000000..62fdd28 --- /dev/null +++ b/backend/database/migrations/040_software_classifications.sql @@ -0,0 +1,10 @@ +-- Tipo de licencia de un programa corregido a mano (gratuito, pago, gratis solo personal o +-- componente). Vale para todos los equipos que lo tengan y gana sobre el clasificador por reglas +-- (softwareClassifier.js), que no se guarda: se calcula al leer. Idempotente. +CREATE TABLE IF NOT EXISTS software_classifications ( + name VARCHAR(255) NOT NULL PRIMARY KEY, + license_type VARCHAR(20) NOT NULL, + updated_by INT NULL, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_software_classifications_user FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/backend/package.json b/backend/package.json index 7ca8196..4e1595c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "4.15.1", + "version": "4.16.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/modules/devices/controller.js b/backend/src/modules/devices/controller.js index c850545..5c8cc3f 100644 --- a/backend/src/modules/devices/controller.js +++ b/backend/src/modules/devices/controller.js @@ -7,6 +7,7 @@ export const putDevice = asyncHandler(async (req, res) => ok(res, await service. export const getDashboard = asyncHandler(async (req, res) => ok(res, await service.getDashboard())); export const getSoftware = asyncHandler(async (req, res) => ok(res, await service.listSoftware(req.query.q, req.query.all))); export const getSoftwareDevices = asyncHandler(async (req, res) => ok(res, await service.devicesWithSoftware(String(req.query.name || '')))); +export const putSoftwareClassification = asyncHandler(async (req, res) => ok(res, await service.setSoftwareClassification(req.body.name, req.body.licenseType, req.currentUser))); export const getRaw = asyncHandler(async (req, res) => { const { full, file } = await service.getRaw(Number(req.params.id)); diff --git a/backend/src/modules/devices/normalizer.js b/backend/src/modules/devices/normalizer.js index d1b54e9..c37f5ee 100644 --- a/backend/src/modules/devices/normalizer.js +++ b/backend/src/modules/devices/normalizer.js @@ -1,4 +1,5 @@ import { XMLParser } from 'fast-xml-parser'; +import { isWindowsBundled } from './softwareClassifier.js'; // Convierte el inventario del agente GLPI (JSON o XML) a un objeto normalizado para el modelo // `devices*`. El JSON nativo y el XML comparten nombres de campo (el XML en MAYUSCULAS), asi que @@ -169,9 +170,10 @@ export function normalizeInventory(parsed) { .map((x) => { const name = str(x.name); const category = str(x.system_category, 40); + const publisher = str(x.publisher, 190); return { - name, version: str(x.version, 120), publisher: str(x.publisher, 190), install_date: str(x.installdate ?? x.install_date, 40), - category, is_component: isComponent(name, category) ? 1 : 0, + name, version: str(x.version, 120), publisher, install_date: str(x.installdate ?? x.install_date, 40), + category, is_component: isComponent(name, category) || isWindowsBundled(name, publisher, category) ? 1 : 0, }; }) .filter((s) => s.name); diff --git a/backend/src/modules/devices/repository.js b/backend/src/modules/devices/repository.js index 6ecf1d1..069a44c 100644 --- a/backend/src/modules/devices/repository.js +++ b/backend/src/modules/devices/repository.js @@ -244,8 +244,8 @@ export async function listSoftware({ search, all }) { if (search) { clauses.push('(s.name LIKE ? OR s.publisher LIKE ?)'); params.push(`%${search}%`, `%${search}%`); } const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; const [rows] = await pool.query( - `SELECT s.name, MAX(s.publisher) AS publisher, COUNT(DISTINCT s.device_id) AS device_count, - COUNT(DISTINCT s.version) AS version_count + `SELECT s.name, MAX(s.publisher) AS publisher, MAX(s.category) AS category, MAX(s.is_component) AS is_component, + COUNT(DISTINCT s.device_id) AS device_count, COUNT(DISTINCT s.version) AS version_count FROM device_software s JOIN devices d ON d.id = s.device_id AND d.status = 'active' ${where} GROUP BY s.name ORDER BY device_count DESC, s.name LIMIT 1000`, params @@ -253,6 +253,19 @@ export async function listSoftware({ search, all }) { return rows.map((r) => ({ ...r, device_count: Number(r.device_count), version_count: Number(r.version_count) })); } +export async function softwareOverrides() { + const [rows] = await pool.query('SELECT name, license_type FROM software_classifications'); + return new Map(rows.map((r) => [r.name.toLowerCase(), r.license_type])); +} + +export const saveSoftwareClassification = (name, type, userId) => pool.query( + `INSERT INTO software_classifications (name, license_type, updated_by) VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE license_type = VALUES(license_type), updated_by = VALUES(updated_by)`, + [name, type, userId] +); + +export const removeSoftwareClassification = (name) => pool.query('DELETE FROM software_classifications WHERE name = ?', [name]); + export async function listDevicesWithSoftware(name) { const [rows] = await pool.query( `SELECT d.id, d.hostname, d.kind, d.last_user, s.version diff --git a/backend/src/modules/devices/routes.js b/backend/src/modules/devices/routes.js index 0c7d9ed..729586f 100644 --- a/backend/src/modules/devices/routes.js +++ b/backend/src/modules/devices/routes.js @@ -5,7 +5,7 @@ import { requireModule } from '../../middleware/modules.js'; import { fail } from '../../utils/http.js'; import { getAccess as getAccessFor } from './service.js'; import { - getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, getRaw, + getDevices, getDevice, putDevice, getDashboard, getSoftware, getSoftwareDevices, putSoftwareClassification, getRaw, getTokens, postToken, patchToken, deleteToken, getTokenSecret, getAccess, getViewers, postViewer, deleteViewer, postCpuYear, } from './controller.js'; @@ -31,6 +31,7 @@ router.get('/access', getAccess); // cualquier autenticado: el menu lo usa para router.get('/dashboard', canView, getDashboard); router.get('/software', canView, getSoftware); router.get('/software/devices', canView, getSoftwareDevices); +router.put('/software/classification', requireRole(...CAN_EDIT_DEVICES), putSoftwareClassification); router.get('/viewers', requireRole(...CAN_MANAGE_DEVICES), getViewers); router.post('/viewers', requireRole(...CAN_MANAGE_DEVICES), postViewer); router.delete('/viewers/:userId', requireRole(...CAN_MANAGE_DEVICES), deleteViewer); diff --git a/backend/src/modules/devices/service.js b/backend/src/modules/devices/service.js index 0c24221..573a461 100644 --- a/backend/src/modules/devices/service.js +++ b/backend/src/modules/devices/service.js @@ -9,6 +9,7 @@ import { encrypt, decrypt } from '../../utils/crypto.js'; import { askStructured } from '../../utils/ai.js'; import * as repo from './repository.js'; import { parseInWorker } from './ingest.js'; +import { classifySoftware, LICENSE_TYPES } from './softwareClassifier.js'; import { CAN_EDIT_DEVICES, CAN_MANAGE_DEVICES } from '../../middleware/auth.js'; const STATUSES = ['active', 'retired']; @@ -26,6 +27,8 @@ export async function getDevice(id) { const device = await repo.findById(id); if (!device) throw new HttpError(404, 'Equipo no encontrado'); if (device.extra?.licenses === undefined && device.raw_file) await backfillLicenses(device); + const overrides = await repo.softwareOverrides(); + device.software = device.software.map((s) => ({ ...s, ...classifySoftware(s, overrides) })); return device; } @@ -91,7 +94,23 @@ export async function getRaw(id) { return { full, file }; } -export const listSoftware = (q, all) => repo.listSoftware({ search: q?.trim() || null, all: !!all }); +export async function listSoftware(q, all) { + const [rows, overrides] = await Promise.all([repo.listSoftware({ search: q?.trim() || null, all: !!all }), repo.softwareOverrides()]); + const classified = rows.map((r) => ({ ...r, ...classifySoftware(r, overrides) })); + return all ? classified : classified.filter((r) => !r.is_component); +} + +// Correccion manual del tipo de licencia de un programa; `licenseType` vacio vuelve a las reglas. +export async function setSoftwareClassification(name, licenseType, actor) { + const program = String(name || '').trim(); + if (!program || program.length > 255) throw new HttpError(400, 'Programa invalido'); + if (licenseType && !LICENSE_TYPES.includes(licenseType)) throw new HttpError(400, 'Tipo de licencia invalido'); + if (licenseType) await repo.saveSoftwareClassification(program, licenseType, actor.id); + else await repo.removeSoftwareClassification(program); + await logAudit({ userId: actor.id, action: 'classify_software', entityType: 'software', entityId: null, detail: { name: program, licenseType: licenseType || null } }); + return { name: program, licenseType: licenseType || null }; +} + export const devicesWithSoftware = (name) => repo.listDevicesWithSoftware(name); export const getDashboard = () => repo.dashboard(STALE_DAYS); diff --git a/backend/src/modules/devices/softwareClassifier.js b/backend/src/modules/devices/softwareClassifier.js new file mode 100644 index 0000000..578d0fd --- /dev/null +++ b/backend/src/modules/devices/softwareClassifier.js @@ -0,0 +1,85 @@ +// Clasificador de software por reglas, sin IA ni consultas externas. Solo mira nombre, fabricante y +// categoria que reporta el agente. Es orientativo: lo que no esta en las listas queda sin clasificar +// (null) y un editor lo corrige a mano (software_classifications), nunca se adivina. + +export const LICENSE_TYPES = ['free', 'paid', 'personal_free', 'component']; + +// Fabricante normalizado: variantes de publisher y programas que solo lo nombran en el titulo. +const VENDORS = [ + ['Epson', /\bepson\b/i], ['Microsoft', /\bmicrosoft\b/i], ['Adobe', /\badobe\b/i], ['Google', /\bgoogle\b/i], + ['HP', /\b(hp|hewlett[- ]packard)\b/i], ['Canon', /\bcanon\b/i], ['Brother', /\bbrother\b/i], + ['Intel', /\bintel\b/i], ['Realtek', /\brealtek\b/i], ['NVIDIA', /\bnvidia\b/i], ['AMD', /\b(amd|advanced micro devices)\b/i], + ['Lenovo', /\blenovo\b/i], ['ASUS', /\basus(tek)?\b/i], ['Samsung', /\bsamsung\b/i], ['Conexant', /\bconexant\b/i], + ['OpenVPN', /\bopenvpn\b/i], ['Notepad++', /\bnotepad\+\+/i], ['Dell', /\bdell\b/i], ['Logitech', /\blogitech\b/i], ['Autodesk', /\bautodesk\b/i], + ['Mozilla', /\bmozilla\b/i], ['Oracle', /\boracle\b/i], +]; +// Traen sus programas con el hardware: lo que no sea un producto de licencia va como incluido. +const HARDWARE_VENDORS = new Set(['Epson', 'HP', 'Canon', 'Brother', 'Intel', 'Realtek', 'NVIDIA', 'AMD', 'Lenovo', 'Dell', 'Logitech', 'ASUS', 'Samsung', 'Conexant']); + +const LEGAL_SUFFIX = /[,.\s]+(corporation|corp|incorporated|inc|ltd|limited|llc|gmbh|s\.?a\.?|co)\.?$/i; + +export function vendorOf(name, publisher) { + for (const [vendor, pattern] of VENDORS) if (pattern.test(publisher || '')) return vendor; + for (const [vendor, pattern] of VENDORS) if (pattern.test(name || '')) return vendor; + // Los paquetes de la tienda reportan el firmante ("CN=..."): no sirve como nombre visible. + if (!publisher || /^cn=/i.test(publisher)) return null; + return publisher.replace(LEGAL_SUFFIX, '').replace(LEGAL_SUFFIX, '').trim() || null; +} + +// Productos Microsoft con licencia propia: nunca se esconden dentro del grupo. +const MICROSOFT_LICENSED = new RegExp([ + '^(microsoft )?windows (10|11|server)\\b(?!.*(asistente|assistant))', + '^microsoft 365\\b(?!.*local ai)', '^microsoft office (profesional|professional|hogar|home|est[aá]ndar|standard|ltsc|\\d{4})', + '^microsoft (visio|project)\\b', '^(microsoft )?visual studio (community|professional|enterprise|\\d{4})', + '^microsoft sql server \\d{4}', '^microsoft dynamics', '^microsoft exchange server', +].join('|'), 'i'); + +const RULES = [ + // Antes que las demas: nombres que tambien entrarian como pago/gratuito. + ['personal_free', /teamviewer|anydesk|winrar|\bwinzip\b|ccleaner|malwarebytes|avast free|avg (anti|free)|macrium reflect free|docker desktop|^java( \d+)?( update| se)|^java\(tm\)|sketchup free/i], + ['free', /acrobat reader|adobe reader|sql server.*express|visual studio code|^microsoft edge\b/i], + ['paid', new RegExp([ + 'acrobat', 'photoshop', 'illustrator', 'indesign', 'premiere pro', 'after effects', 'lightroom', + 'autocad', 'revit', 'sketchup', 'coreldraw', 'solidworks', 'matlab', 'abbyy finereader', 'nitro pro', + 'foxit (pdf )?editor', 'realvnc server', 'kaspersky', '\\beset\\b', 'bitdefender', 'norton', 'mcafee', + ].join('|'), 'i')], + ['free', new RegExp([ + '^7-zip', '^vlc\\b', '^google chrome', 'mozilla (firefox|thunderbird)', '^notepad\\+\\+', 'libreoffice', 'openoffice', + '^putty\\b', '^winscp', '^filezilla', '^git\\b', '^node\\.js', '^python \\d', '^gimp', '^inkscape', '^paint\\.net', + '^pdfcreator', '^audacity', '^keepass', 'openvpn', '^tap-windows', '^xampp', '^mysql (workbench|connector)', '^firmaec', '^nextcloud', + '^rustdesk', '^freefilesync', '^glpi agent', '^android studio', '^composer\\b', '^python launcher', '^nmap\\b', + ].join('|'), 'i')], +]; + +function ruleType(name) { + for (const [type, pattern] of RULES) if (pattern.test(name)) return type; + return null; +} + +// Apps de la tienda y paquetes que vienen con Windows: mismo trato que actualizaciones y drivers. +const WINDOWS_BUNDLED = /desktop runtime|winappruntime|ink\.handwriting|windows web experience|windows package manager|experiencia local|language experience pack|widgets platform/i; +const REAL_STORE_APP = /whiteboard|teams|to do|onenote|outlook|powerpoint|\bexcel\b|\bword\b|clipchamp|power bi|company portal|remote desktop|visio|project/i; + +export function isWindowsBundled(name, publisher, category) { + if (WINDOWS_BUNDLED.test(name || '')) return true; + return String(category || '').toLowerCase() === 'store' && vendorOf(name, publisher) === 'Microsoft' && !REAL_STORE_APP.test(name || ''); +} + +// Devuelve { is_component, license_type, vendor, group }: `group` es el fabricante bajo el que se +// colapsa la fila, null si debe verse suelta. `overrides` es un Map(nombre en minusculas -> tipo). +export function classifySoftware(row, overrides) { + const name = row.name || ''; + const vendor = vendorOf(name, row.publisher); + const isComponent = !!row.is_component || isWindowsBundled(name, row.publisher, row.category); + const licensed = vendor === 'Microsoft' && MICROSOFT_LICENSED.test(name); + let type = isComponent ? 'component' : licensed ? 'paid' : ruleType(name); + if (!type && (vendor === 'Microsoft' || HARDWARE_VENDORS.has(vendor))) type = 'component'; + const manual = overrides?.get(name.toLowerCase()); + return { + is_component: isComponent ? 1 : 0, + license_type: manual || type, + license_manual: !!manual, + vendor, + group: licensed ? null : vendor, + }; +} diff --git a/docs/ARQUITECTURA-INVENTARIO.md b/docs/ARQUITECTURA-INVENTARIO.md index ab60179..c80f313 100644 --- a/docs/ARQUITECTURA-INVENTARIO.md +++ b/docs/ARQUITECTURA-INVENTARIO.md @@ -56,6 +56,15 @@ ausente queda `null` (el formato varía por versión del agente y SO). Tipo de e nombre del SO e `itemtype`. Lo que no tiene columna (impresoras/red SNMP, antivirus, video, batería) se guarda en `devices.extra_json`. +## Clasificación de software + +`softwareClassifier.js` asigna el tipo y el fabricante **al leer**, con reglas de nombre y fabricante +(sin IA ni consultas externas): por eso una regla nueva aplica sin esperar otro reporte. El agente no +informa si un programa es gratis o de pago ni si tiene licencia; el resultado es orientativo. Las +correcciones manuales viven en `software_classifications`. Lo que no está en las listas queda sin +clasificar: no se adivina. La pantalla agrupa por fabricante; los productos Microsoft con licencia +quedan fuera del grupo. + ## Instalar el agente en un equipo 1. Superadmin/admin: menú **Equipos → Agentes → Generar token** (se muestra una sola vez). diff --git a/frontend/package.json b/frontend/package.json index 0910700..4fc3338 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "4.15.1", + "version": "4.16.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ListView.jsx b/frontend/src/components/ListView.jsx index b955f3f..6b93fc4 100644 --- a/frontend/src/components/ListView.jsx +++ b/frontend/src/components/ListView.jsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from 'react'; -import { MoreVertical, GripVertical } from 'lucide-react'; +import { Fragment, useMemo, useState } from 'react'; +import { MoreVertical, GripVertical, ChevronRight, ChevronDown } from 'lucide-react'; import { useUi } from '../context/ui-context.jsx'; import { useDragReorder } from '../hooks/useDragReorder.js'; import { Flyout, FlyoutItem } from './Flyout.jsx'; @@ -9,15 +9,25 @@ import { Flyout, FlyoutItem } from './Flyout.jsx'; // `onReorder(idsEnNuevoOrden)` es opcional: si se pasa, habilita arrastrar filas con el mouse para // definir un orden manual (columna con icono de agarre a la izquierda) — solo mientras no haya un // ordenamiento por columna activo, para no mezclar los dos mecanismos de orden a la vez. -export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder }) { +// `getChildren(fila)` es opcional: si devuelve filas, la fila se pliega/despliega con su boton o con +// un clic y las hijas se muestran debajo. Al buscar, solo quedan las hijas que coinciden y se abren. +export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowClick, rowActions, getRowKey, initialSort, onReorder, getChildren }) { const { t } = useUi(); const [sort, setSort] = useState(initialSort || { key: null, dir: 1 }); + const [expanded, setExpanded] = useState(() => new Set()); + const haystack = (r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase(); + const toggleRow = (key) => setExpanded((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }); const filtered = useMemo(() => { if (!searchValue) return rows; const q = searchValue.toLowerCase(); - return rows.filter((r) => (getSearchHaystack ? getSearchHaystack(r) : JSON.stringify(r)).toLowerCase().includes(q)); - }, [rows, searchValue, getSearchHaystack]); + return rows.filter((r) => haystack(r).includes(q) || getChildren?.(r)?.some((c) => haystack(c).includes(q))); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rows, searchValue, getSearchHaystack, getChildren]); const sorted = useMemo(() => { if (!sort.key) return filtered; @@ -38,6 +48,55 @@ export function ListView({ columns, rows, searchValue, getSearchHaystack, onRowC setSort((prev) => (prev.key === key ? { key, dir: prev.dir * -1 } : { key, dir: 1 })); } + function renderRow(row, { isParent, open, onToggle, child } = {}) { + const actions = rowActions?.(row) || []; + const activate = isParent ? onToggle : onRowClick ? () => onRowClick(row) : null; + return ( +
| e.stopPropagation()} {...getHandleProps(row)}>
- |
- )}
- {columns.map((col) => (
- {col.render ? col.render(row) : row[col.key]} | - ))} - {rowActions && ( - e.stopPropagation()}>
- |
- )}
-