feat: enhance form components with field help and tabbed sections

- Added FieldHelp component to display help text for form fields.
- Integrated help text into AssetCharacteristicControl and ResourceCharacteristicControl.
- Updated AssetForm to utilize TabbedFieldSections for better organization of characteristics.
- Modified BrandSelect to include help text.
- Improved CatalogFieldForm with descriptive hints for the description field.
- Refactored FieldSections to support different layouts and improved styling.
- Introduced fieldHelpById utility to manage help text for fields.
- Updated localization files to include new help text strings.
- Enhanced tests to cover new features and ensure functionality.
This commit is contained in:
David Arranz 2026-10-10 22:00:45 +02:00
parent 853011cec3
commit 832d69c6fa
36 changed files with 1024 additions and 613 deletions

6
.inspect-catalog-db.ts Normal file
View File

@ -0,0 +1,6 @@
import { createDatabase, readDatabaseEnvironment } from "./apps/server/src/config/database";
import { QueryTypes } from "sequelize";
const db=createDatabase(readDatabaseEnvironment(process.env));
console.log(await db.query("SELECT TABLE_NAME AS child,COLUMN_NAME AS col,REFERENCED_TABLE_NAME AS parent,REFERENCED_COLUMN_NAME AS parentCol FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME IS NOT NULL",{type:QueryTypes.SELECT}));
console.log(await db.query("SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",{type:QueryTypes.SELECT}));
await db.close();

455
TODO.md
View File

@ -1,450 +1,5 @@
- Quiero que reestructures el código relativo a la parte web de los módulos. Es decir, el codigo que esá en el directorio: - En la ficha de una SEDE necesito un campo opcional para el teléfono
- En el avatar de la compañía, cuando no hay logotipo, aparecen las iniciles. El tamaño de letra de esas iniciales es muy pequeño. Incrementa el tamaño de forma adeucada a las dimensiones del avatar.
```plaintext - Poder eliminar un activo global personalizado
modules/<nombre_modulo>/src/web/ - En toda la aplicaicón, si hay alguna columna en un listado que es "activo/inactivo", el color para el estado activo es verde.
``` - En el listado de personas de una compañía, muestra el email que tiene esa persona en su ficha
Dentro de ese directorio, la estructura a seguir es crear subdirectorio por las "operaciones" que se pueden hacer. Por ejemplo, si el módulo fueran "facturas", la estructura sería algo así:
```plaintext
📁 modules/customer-invoices/src/web/
├── 📁 change-status
├── 📁 create
├── 📁 delete
├── 📁 list
├── 📁 ...
├── 📁 shared
├── 📁 update
└── 📄 index.ts
```
Fíjate que hay un directorio "shared". Ahí hay código compartido entre todas las "operaciones".
Dentro del directorio de cada operación, hay que seguir también una estructura. Por ejemplo:
```plaintext
├── 📁 change-status
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ └── 📄 use-change-proforma-status-dialog-controller.ts
│ ├── 📁 entities
│ │ ├── 📄 change-proforma-status-target.entity.ts
│ │ └── 📄 index.ts
│ ├── 📁 helpers
│ │ ├── 📄 index.ts
│ │ └── 📄 proforma-status-ui.ts
│ ├── 📁 ui
│ │ ├── 📁 components
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 status-legend.tsx
│ │ │ ├── 📄 status-node.tsx
│ │ │ └── 📄 timeline-connector.tsx
│ │ ├── 📄 change-status-dialog.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 get-common-available-proforma-statuses.ts
│ │ ├── 📄 index.ts
│ │ └── 📄 prepare-change-proforma-status-targets.ts
│ └── 📄 index.ts
```
El arbol completo para este ejemplo de "facturas" quedaría así:
```plaintext
📁 modules/customer-invoices/src/web
├── 📁 change-status
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ └── 📄 use-change-proforma-status-dialog-controller.ts
│ ├── 📁 entities
│ │ ├── 📄 change-proforma-status-target.entity.ts
│ │ └── 📄 index.ts
│ ├── 📁 helpers
│ │ ├── 📄 index.ts
│ │ └── 📄 proforma-status-ui.ts
│ ├── 📁 ui
│ │ ├── 📁 components
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 status-legend.tsx
│ │ │ ├── 📄 status-node.tsx
│ │ │ └── 📄 timeline-connector.tsx
│ │ ├── 📄 change-status-dialog.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 get-common-available-proforma-statuses.ts
│ │ ├── 📄 index.ts
│ │ └── 📄 prepare-change-proforma-status-targets.ts
│ └── 📄 index.ts
├── 📁 create
│ ├── 📁 adapters
│ │ ├── 📄 build-create-proforma-params.test.ts
│ │ ├── 📄 build-create-proforma-params.ts
│ │ └── 📄 index.ts
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ ├── 📄 use-create-proforma-controller.ts
│ │ └── 📄 use-create-proforma-page-controller.ts
│ ├── 📁 entities
│ │ ├── 📄 index.ts
│ │ ├── 📄 proforma-create-form.entity.ts
│ │ └── 📄 proforma-create-form.schema.ts
│ ├── 📁 ui
│ │ ├── 📁 blocks
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-create-auto-applied-info.tsx
│ │ │ ├── 📄 proforma-create-customer-field.tsx
│ │ │ ├── 📄 proforma-create-form.tsx
│ │ │ ├── 📄 proforma-create-header.tsx
│ │ │ └── 📄 proforma-create-initial-info-card.tsx
│ │ ├── 📁 components
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-create-skeleton.test.tsx
│ │ │ └── 📄 proforma-create-skeleton.tsx
│ │ ├── 📁 pages
│ │ │ ├── 📄 index.ts
│ │ │ └── 📄 proforma-create-page.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 build-proforma-create-default.ts
│ │ └── 📄 index.ts
│ └── 📄 index.ts
├── 📁 delete
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ └── 📄 use-delete-proforma-dialog-controller.ts
│ ├── 📁 entities
│ │ ├── 📄 delete-proforma-target.entity.ts
│ │ └── 📄 index.ts
│ ├── 📁 ui
│ │ ├── 📁 components
│ │ │ ├── 📄 delete-proforma-dialog.tsx
│ │ │ └── 📄 index.ts
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 index.ts
│ │ └── 📄 prepare-delete-proforma-targets.ts
│ └── 📄 index.ts
├── 📁 detail
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ ├── 📄 use-create-recurring-proforma-from-proforma-dialog-controller.test.tsx
│ │ ├── 📄 use-create-recurring-proforma-from-proforma-dialog-controller.ts
│ │ ├── 📄 use-create-recurring-proforma-from-proforma-dialog-controller.utils.test.ts
│ │ ├── 📄 use-create-recurring-proforma-from-proforma-dialog-controller.utils.ts
│ │ └── 📄 use-proforma-detail-page-controller.ts
│ ├── 📁 entities
│ │ ├── 📄 index.ts
│ │ └── 📄 proforma-detail-view-models.entity.ts
│ ├── 📁 ui
│ │ ├── 📁 blocks
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-detail-content.tsx
│ │ │ ├── 📄 proforma-detail-header-actions.tsx
│ │ │ ├── 📄 proforma-detail-header.tsx
│ │ │ ├── 📄 proforma-detail-inspector.tsx
│ │ │ ├── 📄 proforma-document-pane.tsx
│ │ │ └── 📄 proforma-general-panel.tsx
│ │ ├── 📁 components
│ │ │ ├── 📄 create-recurring-proforma-from-proforma-dialog.test.tsx
│ │ │ ├── 📄 create-recurring-proforma-from-proforma-dialog.tsx
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-detail-archived-badge.tsx
│ │ │ ├── 📄 proforma-detail-field.tsx
│ │ │ ├── 📄 proforma-detail-formatters.ts
│ │ │ ├── 📄 proforma-detail-header-meta.tsx
│ │ │ ├── 📄 proforma-detail-section.tsx
│ │ │ └── 📄 proforma-detail-skeleton.tsx
│ │ ├── 📁 pages
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-detail-page.test.tsx
│ │ │ └── 📄 proforma-detail-page.tsx
│ │ ├── 📁 sections
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-commercial-section.tsx
│ │ │ ├── 📄 proforma-content-section.tsx
│ │ │ ├── 📄 proforma-customer-section.tsx
│ │ │ ├── 📄 proforma-dates-section.tsx
│ │ │ ├── 📄 proforma-fiscal-section.tsx
│ │ │ ├── 📄 proforma-linked-invoice-section.tsx
│ │ │ └── 📄 proforma-summary-section.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 build-proforma-detail-actions.test.ts
│ │ ├── 📄 build-proforma-detail-actions.ts
│ │ ├── 📄 build-proforma-detail-page-error-view-model.test.ts
│ │ ├── 📄 build-proforma-detail-view-models.ts
│ │ └── 📄 index.ts
│ └── 📄 index.ts
├── 📁 download-pdf
│ ├── 📁 api
│ │ ├── 📄 download-proforma-pdf.api.ts
│ │ └── 📄 index.ts
│ ├── 📁 controller
│ │ ├── 📄 index.ts
│ │ └── 📄 use-download-proforma-pdf.controller.ts
│ ├── 📁 hooks
│ │ ├── 📄 index.ts
│ │ └── 📄 use-download-proforma-pdf-query.ts
│ └── 📄 index.ts
├── 📁 issue-proforma
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ └── 📄 use-issue-proforma-dialog.controller.ts
│ ├── 📁 entities
│ │ ├── 📄 index.ts
│ │ └── 📄 issue-proforma-target.entity.ts
│ ├── 📁 ui
│ │ ├── 📄 index.ts
│ │ └── 📄 issue-proforma-dialog.tsx
│ ├── 📁 utils
│ │ ├── 📄 index.ts
│ │ └── 📄 prepare-issue-proforma-targets.ts
│ └── 📄 index.ts
├── 📁 list
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ ├── 📄 use-list-proformas-page.controller.ts
│ │ ├── 📄 use-list-proformas.controller.ts
│ │ └── 📄 use-proforma-summary-panel.controller.ts
│ ├── 📁 entities
│ │ ├── 📄 index.ts
│ │ └── 📄 proforma-list-page-error-view-model.entity.ts
│ ├── 📁 types
│ │ └── 📄 proforma-list-filters.ts
│ ├── 📁 ui
│ │ ├── 📁 blocks
│ │ │ ├── 📁 proforma-summary-panel
│ │ │ │ ├── 📄 index.ts
│ │ │ │ ├── 📄 proforma-footer-actions.tsx
│ │ │ │ ├── 📄 proforma-header.tsx
│ │ │ │ ├── 📄 proforma-summary-content.tsx
│ │ │ │ ├── 📄 proforma-summary-panel.test.tsx
│ │ │ │ └── 📄 proforma-summary-panel.tsx
│ │ │ ├── 📁 proformas-grid
│ │ │ │ ├── 📄 index.ts
│ │ │ │ ├── 📄 proforma-row-actions.tsx
│ │ │ │ ├── 📄 proformas-grid.tsx
│ │ │ │ ├── 📄 use-proforma-grid-columns.test.tsx
│ │ │ │ └── 📄 use-proforma-grid-columns.tsx
│ │ │ └── 📄 index.ts
│ │ ├── 📁 components
│ │ │ ├── 📄 archive-proforma-dialog.tsx
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-date-preset-filter.tsx
│ │ │ ├── 📄 proforma-status-badge.tsx
│ │ │ ├── 📄 proforma-status-segmented-filter.tsx
│ │ │ └── 📄 unarchive-proforma-dialog.tsx
│ │ ├── 📁 pages
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 list-proformas-page.tsx
│ │ │ ├── 📄 orders-table.tsx
│ │ │ ├── 📄 scoped-list-proformas-page-view.test.tsx
│ │ │ └── 📄 scoped-list-proformas-page-view.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 build-list-proformas-criteria.test.ts
│ │ ├── 📄 build-list-proformas-criteria.ts
│ │ ├── 📄 build-proforma-date-range.test.ts
│ │ ├── 📄 build-proforma-date-range.ts
│ │ ├── 📄 build-proforma-list-page-error-view-model.test.ts
│ │ ├── 📄 build-proforma-list-page-error-view-model.ts
│ │ ├── 📄 resolve-proforma-date-preset.test.ts
│ │ ├── 📄 resolve-proforma-date-preset.ts
│ │ ├── 📄 resolve-proforma-list-feedback-state.test.ts
│ │ └── 📄 resolve-proforma-list-feedback-state.ts
│ └── 📄 index.ts
├── 📁 shared
│ ├── 📁 adapters
│ │ ├── 📄 get-proforma-by-id.adapter.ts
│ │ ├── 📄 get-proforma-issuance-readiness.adapter.ts
│ │ ├── 📄 index.ts
│ │ ├── 📄 list-document-series.adapter.ts
│ │ ├── 📄 list-proformas.adapter.ts
│ │ └── 📄 proforma-to-list-row-patch.adapter.ts
│ ├── 📁 api
│ │ ├── 📄 archive-proforma-by-id.api.ts
│ │ ├── 📄 change-proforma-status-by-id.api.ts
│ │ ├── 📄 create-proforma.api.ts
│ │ ├── 📄 delete-proforma-by-id.api.ts
│ │ ├── 📄 duplicate-proforma-by-id.api.ts
│ │ ├── 📄 get-proforma-by-id.api.ts
│ │ ├── 📄 get-proforma-issuance-readiness.api.ts
│ │ ├── 📄 index.ts
│ │ ├── 📄 issue-proforma-by-id.api.ts
│ │ ├── 📄 list-document-series.api.ts
│ │ ├── 📄 list-proformas-by-criteria.api.ts
│ │ ├── 📄 report-proforma-by-id.api.ts
│ │ ├── 📄 unarchive-proforma-by-id.api.test.ts
│ │ ├── 📄 unarchive-proforma-by-id.api.ts
│ │ └── 📄 update-proforma-by-id.api.ts
│ ├── 📁 constants
│ │ ├── 📄 index.ts
│ │ ├── 📄 payment-method-options.constants.ts
│ │ └── 📄 proforma-fiscal-options.constants.ts
│ ├── 📁 entities
│ │ ├── 📁 forms
│ │ │ ├── 📄 index.ts
│ │ │ └── 📄 proforma-item-form.entity.ts
│ │ ├── 📄 document-series-summary.entity.ts
│ │ ├── 📄 duplicate-proforma-result.entity.ts
│ │ ├── 📄 index.ts
│ │ ├── 📄 proforma-issuance-readiness.entity.ts
│ │ ├── 📄 proforma-item.entity.ts
│ │ ├── 📄 proforma-list-row.entity.ts
│ │ ├── 📄 proforma-list.entity.ts
│ │ ├── 📄 proforma-recipient.entity.ts
│ │ ├── 📄 proforma-status-counts.entity.ts
│ │ ├── 📄 proforma-status.entity.ts
│ │ ├── 📄 proforma-tax-summary.entity.ts
│ │ └── 📄 proforma.entity.ts
│ ├── 📁 hooks
│ │ ├── 📄 index.ts
│ │ ├── 📄 keys.ts
│ │ ├── 📄 proforma-cache-strategy.ts
│ │ ├── 📄 to-validation-errors.ts
│ │ ├── 📄 use-document-series-query.ts
│ │ ├── 📄 use-duplicate-proforma-by-id-mutation.ts
│ │ ├── 📄 use-proforma-archive-mutation.ts
│ │ ├── 📄 use-proforma-change-status-mutation.ts
│ │ ├── 📄 use-proforma-create-mutation.ts
│ │ ├── 📄 use-proforma-delete-mutation.ts
│ │ ├── 📄 use-proforma-get-query.ts
│ │ ├── 📄 use-proforma-issuance-readiness-query.ts
│ │ ├── 📄 use-proforma-issue-mutation.ts
│ │ ├── 📄 use-proforma-unarchive-mutation.ts
│ │ ├── 📄 use-proforma-update-mutation.ts
│ │ └── 📄 use-proformas-list-query.ts
│ ├── 📁 ui
│ │ ├── 📁 blocks
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-issuance-readiness-alert.tsx
│ │ │ └── 📄 proforma-page-header.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📄 build-document-series-select-items.ts
│ │ ├── 📄 build-proforma-payment-term-select-items.test.ts
│ │ ├── 📄 build-proforma-payment-term-select-items.ts
│ │ ├── 📄 calculate-proforma-due-dates.ts
│ │ ├── 📄 ensure-current-value-in-select-items.ts
│ │ ├── 📄 index.ts
│ │ ├── 📄 proforma-fiscal-options.utils.ts
│ │ └── 📄 proforma-issuance-readiness.ts
│ └── 📄 index.ts
├── 📁 update
│ ├── 📁 adapters
│ │ ├── 📄 index.ts
│ │ ├── 📄 map-proforma-items-form-to-proforma-line-inpus.ts
│ │ ├── 📄 map-proforma-items-to-proforma-items-update-form.adapter.ts
│ │ ├── 📄 map-proforma-to-proforma-update-form.adapter.ts
│ │ └── 📄 map-proforma-to-selected-customer.adapter.ts
│ ├── 📁 controllers
│ │ ├── 📄 index.ts
│ │ ├── 📄 use-update-proforma-controller.ts
│ │ ├── 📄 use-update-proforma-customer-change-controller.ts
│ │ ├── 📄 use-update-proforma-items-controller.ts
│ │ ├── 📄 use-update-proforma-page-controller.ts
│ │ ├── 📄 use-update-proforma-payment-controller.ts
│ │ ├── 📄 use-update-proforma-tax-controller.ts
│ │ └── 📄 use-update-proforma-totals-controller.ts
│ ├── 📁 entities
│ │ ├── 📄 index.ts
│ │ ├── 📄 proforma-calculation.entity.ts
│ │ ├── 📄 proforma-item-update-form.entity.ts
│ │ ├── 📄 proforma-item-update-form.schema.ts
│ │ ├── 📄 proforma-item-update-patch.entity.ts
│ │ ├── 📄 proforma-update-form.entity.ts
│ │ ├── 📄 proforma-update-form.schema.test.ts
│ │ ├── 📄 proforma-update-form.schema.ts
│ │ ├── 📄 proforma-update-page-error-view-model.entity.ts
│ │ └── 📄 proforma-update-patch.entity.ts
│ ├── 📁 ui
│ │ ├── 📁 blocks
│ │ │ ├── 📁 selected-recipient
│ │ │ │ ├── 📄 customer-card.tsx
│ │ │ │ ├── 📄 customer-view-dialog.tsx
│ │ │ │ ├── 📄 index.ts
│ │ │ │ └── 📄 selected-recipient-summary.tsx
│ │ │ ├── 📄 editor-sidebar.tsx
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 line-editor.test.tsx
│ │ │ ├── 📄 line-editor.tsx
│ │ │ ├── 📄 new-proforma-totals-summary.tsx
│ │ │ ├── 📄 proforma-compact-totals.tsx
│ │ │ ├── 📄 proforma-line-editor.tsx
│ │ │ ├── 📄 proforma-totals-summary.tsx
│ │ │ ├── 📄 proforma-update-header.test.tsx
│ │ │ └── 📄 proforma-update-header.tsx
│ │ ├── 📁 components
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-info-alert.tsx
│ │ │ ├── 📄 proforma-update-customer-change-dialog.tsx
│ │ │ ├── 📄 proforma-update-help-popover.test.tsx
│ │ │ ├── 📄 proforma-update-help-popover.tsx
│ │ │ └── 📄 proforma-update-skeleton.tsx
│ │ ├── 📁 editors
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-payment-editor.tsx
│ │ │ ├── 📄 proforma-settings-editor.tsx
│ │ │ ├── 📄 proforma-taxes-card.tsx
│ │ │ ├── 📄 proforma-update-editor-form.test.tsx
│ │ │ ├── 📄 proforma-update-editor-form.tsx
│ │ │ ├── 📄 proforma-update-header-editor.tsx
│ │ │ ├── 📄 proforma-update-item-row-editor.tsx
│ │ │ ├── 📄 proforma-update-items-editor.tsx
│ │ │ ├── 📄 proforma-update-items-totals.tsx
│ │ │ ├── 📄 proforma-update-recipient-editor.tsx
│ │ │ └── 📄 proforma-update-tax-editor.tsx
│ │ ├── 📁 pages
│ │ │ ├── 📄 index.ts
│ │ │ ├── 📄 proforma-update-page.test.tsx
│ │ │ └── 📄 proforma-update-page.tsx
│ │ └── 📄 index.ts
│ ├── 📁 utils
│ │ ├── 📁 calculations
│ │ │ ├── 📄 calculate-proforma-line-total.ts
│ │ │ ├── 📄 calculate-proforma-lines-totals.ts
│ │ │ ├── 📄 calculate-proforma-tax-breakdown.ts
│ │ │ ├── 📄 calculate-proforma-totals-from-lines.ts
│ │ │ ├── 📄 calculate-proportional-discount.ts
│ │ │ ├── 📄 index.ts
│ │ │ └── 📄 money-calculation.ts
│ │ ├── 📄 build-proforma-item-update-default.ts
│ │ ├── 📄 build-proforma-update-default.ts
│ │ ├── 📄 build-proforma-update-page-error-view-model.test.ts
│ │ ├── 📄 build-proforma-update-page-error-view-model.ts
│ │ ├── 📄 build-proforma-update-patch.ts
│ │ ├── 📄 build-update-proforma-by-id-params.test.ts
│ │ ├── 📄 build-update-proforma-by-id-params.ts
│ │ ├── 📄 calculate-proforma-totals.ts
│ │ ├── 📄 evaluate-proforma-customer-change.test.ts
│ │ ├── 📄 evaluate-proforma-customer-change.ts
│ │ ├── 📄 index.ts
│ │ ├── 📄 resolve-proforma-update-page-feedback-state.test.ts
│ │ └── 📄 resolve-proforma-update-page-feedback-state.ts
│ └── 📄 index.ts
└── 📄 index.ts
```
Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesites para hacer el cambio bien a la primera.
- En la lista de marcas, quiero poder incluir el logotipo. Como son marcas conocidas, rellena ese nuevo campo buscando en internet el logo correspondiente en formato SVG, sin fondo.
- Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso.
- quiero que el estado plegado/desplegado de la barra lateral izquierda se almacene en el almacenamiento local del navegador para que se conserve entre sesiones.
- revisa el codigo para localizar todos los ficheros en el backend que se refieran a value objetcs de DDD. Debes renombrarlos de esta manera:
email-address.ts => email-address.vo.ts
unique-id.ts => unique-id.vo.ts

View File

@ -44,8 +44,22 @@ export async function verifyAssets(
const initial = await read(global); const initial = await read(global);
assert.equal(initial.items.length, 47); assert.equal(initial.items.length, 75);
assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12); assert.equal(initial.items.filter((item) => item.values.kind === "template").length, 12);
const laptop = initial.items.find(
(item) => item.values.kind === "template" && item.values.name === "Portátil"
);
assert.ok(laptop?.values.kind === "template");
assert.equal(laptop.values.fields.length, 39);
assert.equal(laptop.values.sections?.length, 10);
const batteryState = initial.items.find(
(item) => item.values.kind === "field" && item.values.name === "Estado de batería"
);
assert.ok(batteryState?.values.kind === "field");
assert.deepEqual(batteryState.values.choices, ["Normal", "Desgastada", "Averiada", "Hinchada"]);
assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7")); assert.ok(initial.items.every((item) => item.builtin && item.id.charAt(14) === "7"));
assert.ok( assert.ok(
initial.items.every((item) => initial.items.every((item) =>
@ -55,11 +69,9 @@ export async function verifyAssets(
const stock = initial.items.find((item) => item.values.name === "Procesador"); const stock = initial.items.find((item) => item.values.name === "Procesador");
assert.ok(stock); assert.ok(stock);
await error( const fieldValues = { ...stock.values, description: "Campo global editado" };
`${global}/${stock.id}/update`,
{ values: { ...stock.values, name: "Renombrado" } }, await save(`${global}/${stock.id}/update`, fieldValues);
"BUILTIN_PROTECTED"
);
assert.equal( assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example")) (await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example"))
.status, .status,
@ -71,6 +83,10 @@ export async function verifyAssets(
403 403
); );
assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200); assert.equal((await request(`${global}/${stock.id}/status`, { active: false })).status, 200);
const editedValues = { ...laptop.values, description: "Plantilla global editada" };
const editResponse = await save(`${global}/${laptop.id}/update`, editedValues);
assert.equal(editResponse.savedId, laptop.id);
// Reproduce una BD existente anterior a la columna de icono. // Reproduce una BD existente anterior a la columna de icono.
await database.query("ALTER TABLE asset_definitions DROP COLUMN icon"); await database.query("ALTER TABLE asset_definitions DROP COLUMN icon");
await createAssetModule(database, (_request, _response, next) => next()); await createAssetModule(database, (_request, _response, next) => next());
@ -78,11 +94,15 @@ export async function verifyAssets(
const restarted = await read(global); const restarted = await read(global);
assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false); assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false);
assert.ok( assert.deepEqual(restarted.items.find((item) => item.id === stock.id)?.values, fieldValues);
restarted.items const editedLaptop = restarted.items.find((item) => item.id === laptop.id);
.filter((item) => item.values.kind === "template")
.every((item) => item.values.kind === "template" && item.values.icon) assert.ok(editedLaptop?.values.kind === "template");
); assert.equal(editedLaptop.values.description, editedValues.description);
assert.equal(editedLaptop.values.icon, undefined);
assert.deepEqual(editedLaptop.values.fields, laptop.values.fields);
assert.deepEqual(editedLaptop.values.sections, laptop.values.sections);
assert.deepEqual(editedLaptop.bindings, laptop.bindings);
assert.deepEqual( assert.deepEqual(
restarted.items.map((item) => item.id).sort(), restarted.items.map((item) => item.id).sort(),
initial.items.map((item) => item.id).sort() initial.items.map((item) => item.id).sort()

View File

@ -172,11 +172,21 @@ export async function verifyResources(
const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo"); const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo");
assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template"); assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template");
await error( const editedValues = { ...stockTemplate.values, description: "Plantilla global editada" };
`${global}/${stock.id}/update`, const edited = await save(`${global}/${stockTemplate.id}/update`, editedValues);
{ values: { ...stock.values, name: "Cambio" } },
"BUILTIN_PROTECTED" assert.equal(edited.savedId, stockTemplate.id);
); await createResourceModule(database, (_request, _response, next) => next());
const editedCatalog = await read(global);
const editedTemplate = editedCatalog.items.find((item) => item.id === stockTemplate.id);
assert.ok(editedTemplate?.values.kind === "template");
assert.deepEqual(editedTemplate.values, editedValues);
assert.deepEqual(editedTemplate.bindings, stockTemplate.bindings);
const fieldValues = { ...stock.values, description: "Campo global editado" };
await save(`${global}/${stock.id}/update`, fieldValues);
assert.equal( assert.equal(
(await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example")) (await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example"))
.status, .status,
@ -195,6 +205,8 @@ export async function verifyResources(
await createResourceModule(database, (_request, _response, next) => next()); await createResourceModule(database, (_request, _response, next) => next());
const restarted = await read(global); const restarted = await read(global);
assert.deepEqual(restarted.items.find((item) => item.id === stock.id)?.values, fieldValues);
assert.deepEqual( assert.deepEqual(
restarted.items.map((item) => item.id).sort(), restarted.items.map((item) => item.id).sort(),
initial.items.map((item) => item.id).sort() initial.items.map((item) => item.id).sort()

View File

@ -15,13 +15,13 @@ El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta d
## Datos de serie ## Datos de serie
Se incluyen además cinco [tipos de relación](relaciones-entre-activos.md). Hay 30 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI. Se incluyen además cinco [tipos de relación](relaciones-entre-activos.md). Hay 58 campos y 12 plantillas: portátil, PC de sobremesa, monitor, impresora, router, firewall, switch, punto de acceso Wi-Fi, NAS, servidor, smartphone y SAI.
Los campos cubren marca, modelo, número de serie, compra y garantía; procesador, RAM, almacenamiento y sistema operativo; pantalla, resolución y conexiones; IP, MAC, puertos, velocidad, PoE y Wi-Fi; discos, capacidad y RAID; impresión; telefonía; potencia y autonomía. Los campos cubren marca, modelo, número de serie, compra y garantía; procesador, RAM, almacenamiento y sistema operativo; pantalla, resolución y conexiones; IP, MAC, puertos, velocidad, PoE y Wi-Fi; discos, capacidad y RAID; impresión; telefonía; potencia y autonomía.
Todas las características de las plantillas de serie son opcionales. Las definiciones de serie no se pueden renombrar ni alterar, tampoco mediante la API. Se pueden activar o desactivar. Para cambiar los campos de una plantilla de serie, crear una copia. No hay borrado de definiciones. Todas las características iniciales de las plantillas de serie son opcionales. Las plantillas globales, incluidas las de serie, permiten editar nombre, descripción, icono, grupos, campos incluidos y obligatoriedad. Los campos de serie admiten edición; su tipo permanece fijo y las opciones existentes no se pueden retirar. Los tipos de relación de serie mantienen su protección. Se pueden activar o desactivar. Para particularizar una plantilla para una compañía, crear una copia. No hay borrado de definiciones.
El arranque alimenta únicamente definiciones ausentes mediante claves de serie estables. Reiniciar no duplica registros, regenera UUID ni reactiva definiciones desactivadas. No crea compañías, personas ni activos ficticios. El arranque incorpora únicamente definiciones ausentes mediante claves de serie estables; conserva las plantillas existentes y su personalización. Reiniciar no duplica registros, regenera UUID ni reactiva definiciones desactivadas. No crea compañías, personas ni activos ficticios.
## Ámbitos y disponibilidad ## Ámbitos y disponibilidad
@ -53,8 +53,27 @@ Las plantillas permiten organizar campos en secciones ordenadas, globales o prop
Cada plantilla global o propia de compañía admite un icono opcional de la biblioteca Lucide existente. El formulario incluye un selector visual con nombres traducidos, selección marcada y opción «Sin icono». Las copias conservan el icono; las plantillas personalizadas permiten cambiarlo y retirarlo. Cada plantilla global o propia de compañía admite un icono opcional de la biblioteca Lucide existente. El formulario incluye un selector visual con nombres traducidos, selección marcada y opción «Sin icono». Las copias conservan el icono; las plantillas personalizadas permiten cambiarlo y retirarlo.
Las doce plantillas de serie incluyen iconos adecuados a cada equipo. El arranque completa los iconos ausentes de instalaciones existentes de forma idempotente, conservando identidades, campos, secciones y disponibilidad. Se mantiene la protección de las definiciones de serie. Las doce plantillas de serie incluyen iconos adecuados a cada equipo. Editar o retirar un icono conserva identidades, campos y disponibilidad; el arranque no repone los iconos retirados por el usuario.
El catálogo, su consulta de detalle, la columna Plantilla del inventario y la cabecera del panel del activo muestran el icono. Los activos toman el icono actual de su plantilla: es metadato de presentación y no modifica características ni historial técnico. El catálogo, su consulta de detalle, la columna Plantilla del inventario y la cabecera del panel del activo muestran el icono. Los activos toman el icono actual de su plantilla: es metadato de presentación y no modifica características ni historial técnico.
Se persiste la clave validada del icono en la columna nullable `asset_definitions.icon`, sin JSON ni archivos de imagen. La migración añade la columna en instalaciones existentes. Los campos y las relaciones no admiten iconos en este contrato. Se persiste la clave validada del icono en la columna nullable `asset_definitions.icon`, sin JSON ni archivos de imagen. La migración añade la columna en instalaciones existentes. Los campos y las relaciones no admiten iconos en este contrato.
## Plantilla Portátil
La plantilla incluye diez grupos, con todos los campos opcionales:
- Identificación: marca, modelo, número de serie y código de producto / Service Tag.
- Hardware: procesador, tarjeta gráfica, webcam, Wi-Fi y Bluetooth.
- Memoria RAM: capacidad, tipo, distribución, ranuras libres y memoria soldada.
- Almacenamiento: descripción/capacidad, modelo, interfaz, formato y ranuras libres.
- Pantalla: pulgadas, resolución, pantalla táctil y referencia del panel.
- Teclado: distribución/idioma, teclado numérico y retroiluminación.
- Batería: referencia, capacidad de diseño y máxima actual (Wh), ciclos, estado y fecha de comprobación. Estados: Normal, Desgastada, Averiada e Hinchada; sin información se deja vacío.
- Alimentación: potencia del cargador (W), conector y referencia.
- Compra y garantía: fecha de compra y fin de garantía.
- Soporte y reparación: enlace al manual de servicio y observaciones técnicas.
Se reutilizan los campos existentes de RAM, almacenamiento y teclado numérico; los diagnósticos detallados y reparaciones siguen perteneciendo a las intervenciones. Sistema, Red y Conexiones no forman parte de esta plantilla.
La plantilla inicial Portátil incluye estos campos y grupos. El arranque no sobrescribe las plantillas existentes; pueden revisarse desde el catálogo global conservando su UUID y disponibilidad. Las definiciones de campos retirados siguen disponibles para otras plantillas. No se modifican las copias personalizadas ni las instantáneas, características o historial de activos ya registrados.

View File

@ -141,7 +141,7 @@ Los entornos necesitan un sistema activo seleccionado. Los permisos nuevos hered
Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas. Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas.
El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las definiciones de serie se consultan en modales de lectura; su personalización se realiza mediante copias. El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las plantillas de serie admiten edición modal desde el catálogo global; para personalizar exclusivamente una compañía se crean copias. Los campos de serie admiten edición modal desde el catálogo global.
El inventario añade altas rápidas de plantilla y sede en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No. El inventario añade altas rápidas de plantilla y sede en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No.
@ -338,3 +338,31 @@ Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de
Las fichas de plantillas de activos y recursos usan `FieldSections layout="rows"` y `TemplateFieldSummary`. Secciones plegables abiertas inicialmente, con títulos en mayúsculas de tamaño pequeño y color `muted-foreground`, sin tarjetas ni rellenos anidados. El indicador ocupa una anchura fija y los campos se alinean con el inicio del texto del título; filas compactas con nombre, tipo/unidad y obligatoriedad. Los campos desactivados conservan su aviso y los campos sin sección siguen visibles. En pantallas amplias se muestran dos columnas de secciones; en anchos menores, una. Cada fila adapta sus metadatos al ancho real de su sección. Las fichas de plantillas de activos y recursos usan `FieldSections layout="rows"` y `TemplateFieldSummary`. Secciones plegables abiertas inicialmente, con títulos en mayúsculas de tamaño pequeño y color `muted-foreground`, sin tarjetas ni rellenos anidados. El indicador ocupa una anchura fija y los campos se alinean con el inicio del texto del título; filas compactas con nombre, tipo/unidad y obligatoriedad. Los campos desactivados conservan su aviso y los campos sin sección siguen visibles. En pantallas amplias se muestran dos columnas de secciones; en anchos menores, una. Cada fila adapta sus metadatos al ancho real de su sección.
La ayuda de consulta usa `ContextHelp variant="plain"`: separación superior discreta, sin caja exterior ni fondo en el desplegable. Los formularios mantienen la presentación existente por defecto. `FormDialog.title` admite contenido React para colocar un icono decorativo junto al título sin una segunda cabecera repetida. La ayuda de consulta usa `ContextHelp variant="plain"`: separación superior discreta, sin caja exterior ni fondo en el desplegable. Los formularios mantienen la presentación existente por defecto. `FormDialog.title` admite contenido React para colocar un icono decorativo junto al título sin una segunda cabecera repetida.
## Formulario compacto de activos
El alta y la edición de activos usan `FormSection variant="plain"` para separar identificación y características mediante títulos pequeños en mayúsculas y color `muted-foreground`, sin tarjetas anidadas. Nombre, plantilla, estado y sede comparten una rejilla adaptable.
Para formularios que muestran todos los grupos juntos, `FieldSections layout="form"` presenta cada grupo plegable de ancho completo, abierto inicialmente, con separador tenue bajo el título. Los controles se alinean con el texto del título y pasan de una a dos, tres o cuatro columnas según el ancho real del contenedor, conservando el orden de los campos. No se emparejan grupos de distinta altura, evitando huecos entre secciones. Los campos sin grupo permanecen visibles; los grupos con controles inválidos se abren para permitir su enfoque.
La ayuda inferior utiliza `ContextHelp variant="plain"`. Guardar y Cancelar permanecen en el pie fijo del diálogo; el cuerpo conserva desplazamiento cuando sea necesario. Las variantes anteriores continúan disponibles para los demás formularios. Esta distribución no modifica validación, instantáneas, valores ni operaciones de alta relacionada.
### Navegación entre grupos de características
El formulario de activos mantiene nombre, plantilla, estado y sede siempre visibles en su cabecera; notas y ayuda permanecen después de las características. `TabbedFieldSections` de `@repo/rdx-ui` presenta una pestaña por grupo configurado en la plantilla, omitiendo grupos vacíos y conservando los campos sin grupo. No introduce categorías fijas de Metis Desk.
En escritorio, los grupos se navegan en una columna vertical a la izquierda de los controles. En móvil, la lista pasa a horizontal con desplazamiento; la orientación de teclado también se adapta mediante las primitivas Tabs de shadcn/ui. Los paneles se mantienen montados para conservar los valores; al cambiar de plantilla, se inicia la navegación en su primer grupo.
Antes del envío, el formulario localiza el primer control inválido y solicita la validación nativa. El panel correspondiente se activa de forma síncrona antes de que el navegador enfoque el control. Se mantienen la validación de esquema, los errores, la ayuda inferior y el pie fijo de operaciones.
## Edición de plantillas globales
Los catálogos de activos y recursos ofrecen Editar para las plantillas y campos globales de serie desde el ámbito global, reutilizando sus formularios modales. En una compañía, las plantillas globales se consultan, se copian o se activa/desactiva su disponibilidad; la edición del global se realiza desde su catálogo. Los campos de serie admiten edición con tipo fijo y opciones ampliables; los tipos de relación de serie siguen protegidos.
La edición conserva UUID, clave de serie y las identidades de asociaciones que continúan. La carga inicial no sobrescribe plantillas existentes, incluidos grupos vacíos e iconos retirados. Los activos y recursos creados conservan sus instantáneas: la modificación de una plantilla se aplica a futuras altas. El uso especializado de una plantilla de recurso permanece fijo para conservar sus integraciones.
## Texto de ayuda de campos de activos y recursos
El atributo `description` de las definiciones de campo se presenta como «Texto de ayuda (opcional)», con una explicación de dónde aparecerá. En los formularios de alta y edición se muestra bajo el control, con tipografía pequeña, color `muted-foreground` y saltos de línea conservados. Se reutiliza `FieldDescription` de shadcn/ui y se vincula mediante `aria-describedby`, incluidos desplegables y el selector de marca.
La ayuda se consulta en el catálogo de cada ámbito por el identificador del campo, manteniendo separados activos y recursos. En edición se utiliza la ayuda vigente del catálogo sin modificar los tipos, etiquetas ni valores de la especificación capturada. Actualizar un texto de ayuda no requiere alterar datos ni historial de los activos o recursos existentes. Si el texto está vacío o falta la definición, no se muestra un bloque vacío.

View File

@ -16,7 +16,7 @@ Una plantilla reúne campos de recursos. Las plantillas propias pueden declarar
Hay definiciones globales y adiciones por compañía. Una plantilla global solo admite campos globales. Una plantilla local admite campos globales y de su propia compañía. Los nombres del mismo tipo no se duplican en el catálogo visible, ignorando mayúsculas y espacios exteriores. Hay definiciones globales y adiciones por compañía. Una plantilla global solo admite campos globales. Una plantilla local admite campos globales y de su propia compañía. Los nombres del mismo tipo no se duplican en el catálogo visible, ignorando mayúsculas y espacios exteriores.
Cada compañía puede activar o desactivar una definición global para su ámbito. Usar configuración global elimina el efecto del ajuste local. Las definiciones de serie no se pueden editar; pueden desactivarse o copiarse como plantillas personalizables. No hay borrado físico. Cada compañía puede activar o desactivar una definición global para su ámbito. Usar configuración global elimina el efecto del ajuste local. Las plantillas de serie se pueden editar desde el catálogo global; los campos de serie también se pueden editar globalmente, conservando su tipo y sus opciones existentes. Para personalizar una plantilla exclusivamente para una compañía, se puede crear una copia. No hay borrado físico.
## Datos de serie ## Datos de serie
@ -85,7 +85,7 @@ Una licencia es un Resource de la compañía creado con la plantilla de serie Li
Nombre, plantilla y estado siguen el contrato común. Todas las características de esta plantilla son opcionales. Modalidad ofrece Suscripción, Perpetua, OEM y Otra; criterio ofrece Por usuario, Por dispositivo, Por servidor, Concurrente, Corporativa y Otro. Cantidad contratada es una característica numérica documental; no representa plazas libres ni impone aún reglas de asignación. Caducidad y fecha de renovación se registran independientemente. Renovación automática distingue sí, no y sin datos. No almacenar claves de activación. Nombre, plantilla y estado siguen el contrato común. Todas las características de esta plantilla son opcionales. Modalidad ofrece Suscripción, Perpetua, OEM y Otra; criterio ofrece Por usuario, Por dispositivo, Por servidor, Concurrente, Corporativa y Otro. Cantidad contratada es una característica numérica documental; no representa plazas libres ni impone aún reglas de asignación. Caducidad y fecha de renovación se registran independientemente. Renovación automática distingue sí, no y sin datos. No almacenar claves de activación.
Se reutilizan proveedor, referencia, caducidad y descripción del catálogo de recursos. Los siete campos nuevos y la plantilla se incorporan de forma idempotente al arrancar, sin cambiar identidades, fichas existentes ni disponibilidad configurada. La plantilla de serie está protegida y se puede copiar para personalizar; se puede desactivar globalmente o para una compañía. La ayuda específica aparece al final del modal, antes de Guardar y Cancelar. Se reutilizan proveedor, referencia, caducidad y descripción del catálogo de recursos. Los siete campos nuevos y la plantilla se incorporan de forma idempotente al arrancar, sin cambiar identidades, fichas existentes ni disponibilidad configurada. La plantilla de serie se puede editar globalmente o copiar para personalizarla en una compañía; se puede desactivar globalmente o para una compañía. La ayuda específica aparece al final del modal, antes de Guardar y Cancelar.
La ficha captura su especificación y conserva revisiones como cualquier recurso. Registrar una licencia no crea asignaciones, vínculos con aplicaciones/cuentas/equipos, avisos de vencimiento ni cálculos de capacidad. Estas relaciones y avisos quedan pendientes de una entrega posterior. La ficha captura su especificación y conserva revisiones como cualquier recurso. Registrar una licencia no crea asignaciones, vínculos con aplicaciones/cuentas/equipos, avisos de vencimiento ni cálculos de capacidad. Estas relaciones y avisos quedan pendientes de una entrega posterior.
@ -97,3 +97,5 @@ Las plantillas permiten organizar campos en secciones ordenadas, globales o prop
## Persistencia relacional ## Persistencia relacional
El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md). El catálogo, las especificaciones congeladas y los valores actuales e históricos de recursos se almacenan en columnas y tablas relacionadas. Véase [normalización de la persistencia](normalizacion-de-la-persistencia.md).
El arranque añade únicamente las definiciones ausentes y no sobrescribe las plantillas existentes. Las ediciones conservan UUID y clave de serie; el uso especializado de la plantilla permanece fijo. Los recursos ya creados conservan su especificación capturada, incluso si posteriormente se retiran campos de la plantilla.

View File

@ -6,13 +6,47 @@ import type { AssetCatalog } from "./asset-catalog.aggregate";
import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition"; import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
const FIELDS: readonly [string, string, AssetFieldType, string][] = [ const FIELDS: readonly [string, string, AssetFieldType, string, (readonly string[])?][] = [
["product_code", "Código de producto / Service Tag", "text", ""],
["memory_type", "Tipo de RAM", "text", ""],
["memory_layout", "Distribución de RAM", "text", ""],
["memory_free_slots", "Ranuras libres de RAM", "number", ""],
["memory_soldered", "Memoria soldada", "boolean", ""],
["disk_model", "Modelo del disco", "text", ""],
["disk_interface", "Interfaz del disco", "text", ""],
["disk_format", "Formato del disco", "text", ""],
["disk_free_slots", "Ranuras libres de almacenamiento", "number", ""],
["touchscreen", "Pantalla táctil", "boolean", ""],
["panel_reference", "Referencia del panel", "text", ""],
["keyboard_layout", "Distribución / idioma del teclado", "text", ""],
["keyboard_backlight", "Teclado retroiluminado", "boolean", ""],
["battery_reference", "Referencia / modelo de batería", "text", ""],
["battery_design_capacity", "Capacidad de diseño de batería", "number", "Wh"],
["battery_full_capacity", "Capacidad máxima actual de batería", "number", "Wh"],
["battery_cycles", "Número de ciclos de batería", "number", ""],
[
"battery_status",
"Estado de batería",
"choice",
"",
["Normal", "Desgastada", "Averiada", "Hinchada"],
],
["battery_checked_on", "Fecha de comprobación de batería", "date", ""],
["charger_power", "Potencia del cargador", "number", "W"],
["charger_connector", "Tipo de conector del cargador", "text", ""],
["charger_reference", "Referencia / modelo del cargador", "text", ""],
["service_manual", "Enlace al manual de servicio", "text", ""],
["technical_notes", "Observaciones técnicas", "text", ""],
["brand", "Marca", "text", ""], ["brand", "Marca", "text", ""],
["model", "Modelo", "text", ""], ["model", "Modelo", "text", ""],
["serial", "Número de serie", "text", ""], ["serial", "Número de serie", "text", ""],
["processor", "Procesador", "text", ""], ["processor", "Procesador", "text", ""],
["memory", "Memoria RAM", "number", "GB"], ["memory", "Memoria RAM", "number", "GB"],
["storage", "Almacenamiento", "text", ""], ["storage", "Almacenamiento", "text", ""],
["graphics", "Tarjeta gráfica", "text", ""],
["webcam", "Webcam", "text", ""],
["bluetooth", "Versión de Bluetooth", "text", ""],
["numeric_keypad", "Teclado numérico", "boolean", ""],
["os", "Sistema operativo", "text", ""], ["os", "Sistema operativo", "text", ""],
["hostname", "Nombre de equipo", "text", ""], ["hostname", "Nombre de equipo", "text", ""],
["screen_size", "Pulgadas", "number", "pulgadas"], ["screen_size", "Pulgadas", "number", "pulgadas"],
@ -39,22 +73,37 @@ const FIELDS: readonly [string, string, AssetFieldType, string][] = [
["warranty_until", "Fin de garantía", "date", ""], ["warranty_until", "Fin de garantía", "date", ""],
]; ];
const COMMON = ["brand", "model", "serial", "purchased_on", "warranty_until"]; const COMMON = ["brand", "model", "serial", "purchased_on", "warranty_until"];
const LAPTOP_SECTION_FIELDS: readonly [string, readonly string[]][] = [
["Identificación", ["brand", "model", "serial", "product_code"]],
["Hardware", ["processor", "graphics", "webcam", "wifi", "bluetooth"]],
[
"Memoria RAM",
["memory", "memory_type", "memory_layout", "memory_free_slots", "memory_soldered"],
],
["Almacenamiento", ["storage", "disk_model", "disk_interface", "disk_format", "disk_free_slots"]],
["Pantalla", ["screen_size", "resolution", "touchscreen", "panel_reference"]],
["Teclado", ["keyboard_layout", "numeric_keypad", "keyboard_backlight"]],
[
"Batería",
[
"battery_reference",
"battery_design_capacity",
"battery_full_capacity",
"battery_cycles",
"battery_status",
"battery_checked_on",
],
],
["Alimentación", ["charger_power", "charger_connector", "charger_reference"]],
["Compra y garantía", ["purchased_on", "warranty_until"]],
["Soporte y reparación", ["service_manual", "technical_notes"]],
];
const TEMPLATES: readonly [string, string, readonly string[]][] = [ const TEMPLATES: readonly [string, string, readonly string[]][] = [
[ [
"laptop", "laptop",
"Portátil", "Portátil",
[ LAPTOP_SECTION_FIELDS.flatMap(([, keys]) => keys).filter((key) => !COMMON.includes(key)),
"processor",
"memory",
"storage",
"os",
"hostname",
"screen_size",
"resolution",
"connections",
"ip",
"mac",
],
], ],
[ [
"desktop", "desktop",
@ -120,19 +169,27 @@ export function seedAssetCatalog(catalog: AssetCatalog) {
current = result.data; current = result.data;
}; };
for (const [key, name, type, unit] of FIELDS) { for (const [key, name, type, unit, choices = []] of FIELDS) {
add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] }); add(`field:${key}`, {
kind: "field",
name,
description: "",
type,
unit,
choices: [...choices],
});
} }
for (const [key, name, fields] of TEMPLATES) { for (const [key, name, fields] of TEMPLATES) {
const sectionFields = [...COMMON, ...fields]; const sectionFields = [...COMMON, ...fields];
const sections = SECTION_FIELDS.filter(([, keys]) => const sectionLayout = key === "laptop" ? LAPTOP_SECTION_FIELDS : SECTION_FIELDS;
keys.some((field) => sectionFields.includes(field)) const sections = sectionLayout
).map(([name, keys]) => ({ .filter(([, keys]) => keys.some((field) => sectionFields.includes(field)))
id: UniqueID.generateNewID().toPrimitive(), .map(([name, keys]) => ({
name, id: UniqueID.generateNewID().toPrimitive(),
description: "", name,
keys, description: "",
})); keys,
}));
const references = [...COMMON, ...fields].map((field) => { const references = [...COMMON, ...fields].map((field) => {
const id = current.definitions const id = current.definitions
.find((item) => item.stockKey.getOrUndefined() === `field:${field}`) .find((item) => item.stockKey.getOrUndefined() === `field:${field}`)

View File

@ -137,11 +137,18 @@ export class AssetCatalog extends AggregateRoot<CatalogProps> {
}); });
} }
seed(stockKey: string, values: AssetDefinitionValues): Result<AssetCatalog, AssetCatalogError> { seed(
stockKey: string,
values: AssetDefinitionValues,
options: { refreshTemplate?: boolean } = {}
): Result<AssetCatalog, AssetCatalogError> {
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
if (previous) { if (previous) {
const upgraded = previous.initializeSections(values).initializeIcon(values); if (!options.refreshTemplate) {
return Result.ok(this);
}
const upgraded = previous.refreshBuiltinTemplate(values);
if (upgraded === previous) { if (upgraded === previous) {
return Result.ok(this); return Result.ok(this);

View File

@ -8,7 +8,7 @@ import { AssetCatalog } from "./asset-catalog.aggregate";
import { seedAssetCatalog } from "./asset-catalog-seed"; import { seedAssetCatalog } from "./asset-catalog-seed";
import type { AssetDefinitionSpec } from "./asset-definition"; import type { AssetDefinitionSpec } from "./asset-definition";
import { AssetDefinitionSchema } from "./asset-definition"; import { AssetDefinitionSchema } from "./asset-definition";
import { AssetDefinition } from "./asset-definition.entity"; import { AssetDefinition, TemplateField } from "./asset-definition.entity";
import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo"; import { AssetDefinitionValues } from "./value-objects/asset-definition-values.vo";
const global = Maybe.none<UniqueID>(); const global = Maybe.none<UniqueID>();
@ -34,7 +34,7 @@ function changed(result: ReturnType<AssetCatalog["save"]>) {
test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => { test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => {
const catalog = seedAssetCatalog(AssetCatalog.create()); const catalog = seedAssetCatalog(AssetCatalog.create());
assert.equal(catalog.definitions.length, 47); assert.equal(catalog.definitions.length, 75);
assert.equal(seedAssetCatalog(catalog), catalog); assert.equal(seedAssetCatalog(catalog), catalog);
const ids = [ const ids = [
catalog.id, catalog.id,
@ -60,8 +60,11 @@ test("el catálogo inicial es idempotente, con UUID propios y características o
assert.ok(stock); assert.ok(stock);
const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio")); const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio"));
assert.ok(!protectedResult.isSuccess); assert.ok(protectedResult.isSuccess);
assert.equal(protectedResult.error.code, "BUILTIN_PROTECTED"); assert.equal(
protectedResult.data.definitions.find((item) => item.id.equals(stock.id))?.values.data.name,
"Cambio"
);
}); });
test("la disponibilidad por compañía conserva el global y los campos desactivados", () => { test("la disponibilidad por compañía conserva el global y los campos desactivados", () => {
let catalog = seedAssetCatalog(AssetCatalog.create()); let catalog = seedAssetCatalog(AssetCatalog.create());
@ -74,7 +77,7 @@ test("la disponibilidad por compañía conserva el global y los campos desactiva
assert.equal(catalog.active(stock, first), false); assert.equal(catalog.active(stock, first), false);
assert.equal(catalog.active(stock, second), true); assert.equal(catalog.active(stock, second), true);
assert.equal(stock.active, true); assert.equal(stock.active, true);
assert.equal(catalog.definitions.length, 47); assert.equal(catalog.definitions.length, 75);
const overrideId = catalog.availability[0]?.id; const overrideId = catalog.availability[0]?.id;
assert.ok(overrideId); assert.ok(overrideId);
@ -203,7 +206,7 @@ test("iconos opcionales: cambio y retirada conservan identidad y asociaciones",
} }
}); });
test("el arranque completa iconos de serie sin cambiar UUID, campos o disponibilidad", () => { test("el arranque conserva un icono de serie retirado sin cambiar UUID, campos o disponibilidad", () => {
const initial = seedAssetCatalog(AssetCatalog.create()); const initial = seedAssetCatalog(AssetCatalog.create());
const template = initial.definitions.find( const template = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:laptop" (item) => item.stockKey.getOrUndefined() === "template:laptop"
@ -238,7 +241,7 @@ test("el arranque completa iconos de serie sin cambiar UUID, campos o disponibil
const saved = upgraded.definitions.find((item) => item.id.equals(template.id)); const saved = upgraded.definitions.find((item) => item.id.equals(template.id));
assert.ok(saved?.values.data.kind === "template"); assert.ok(saved?.values.data.kind === "template");
assert.equal(saved.values.data.icon, "laptop"); assert.equal(saved.values.data.icon, undefined);
assert.equal(saved.active, false); assert.equal(saved.active, false);
assert.deepEqual(saved.values.data.fields, template.values.data.fields); assert.deepEqual(saved.values.data.fields, template.values.data.fields);
assert.deepEqual(saved.bindings, template.bindings); assert.deepEqual(saved.bindings, template.bindings);
@ -270,3 +273,265 @@ test("los iconos desconocidos o asociados a campos se rechazan", () => {
false false
); );
}); });
test("revisión explícita de Portátil conserva identidades, copias y otras plantillas", () => {
const initial = seedAssetCatalog(AssetCatalog.create());
const laptop = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:laptop"
);
assert.ok(laptop?.values.data.kind === "template");
const oldLayout: [string, string[]][] = [
["Identificación", ["brand", "model", "serial"]],
["Hardware", ["processor", "memory", "storage"]],
["Sistema", ["os", "hostname"]],
["Pantalla y conexiones", ["screen_size", "resolution", "connections"]],
["Red", ["ip", "mac"]],
["Compra y garantía", ["purchased_on", "warranty_until"]],
];
const sections = oldLayout.map(([name]) => ({
id: UniqueID.generateNewID().toPrimitive(),
name,
description: "",
}));
const fields = oldLayout.flatMap(([, keys], index) =>
keys.map((key) => {
const field = initial.definitions.find(
(item) => item.stockKey.getOrUndefined() === `field:${key}`
);
assert.ok(field);
return { fieldId: field.id.toPrimitive(), required: false, sectionId: sections[index]?.id };
})
);
const oldValues = values({ ...laptop.values.data, fields, sections });
const bindings = fields.map((field) => {
const definition = initial.definitions.find((item) => item.id.toPrimitive() === field.fieldId);
assert.ok(definition);
return TemplateField.create(definition.id, false);
});
const oldLaptop = AssetDefinition.rehydrate(
{
companyId: laptop.companyId,
stockKey: laptop.stockKey,
values: oldValues,
active: false,
bindings,
},
laptop.id
);
const copy = AssetDefinition.create(
global,
values({ ...oldLaptop.values.data, name: "Copia personalizada" }),
true
);
const before = AssetCatalog.rehydrate(
{
definitions: [
...initial.definitions.filter((item) => !item.id.equals(laptop.id)),
oldLaptop,
copy,
],
availability: initial.availability,
},
initial.id
);
const revision = before.seed("template:laptop", laptop.values, { refreshTemplate: true });
assert.ok(revision.isSuccess);
const after = revision.data;
const updated = after.definitions.find((item) => item.id.equals(laptop.id));
assert.ok(updated?.values.data.kind === "template");
assert.equal(updated.active, false);
assert.deepEqual(
updated.values.data.sections?.map((section) => section.name),
[
"Identificación",
"Hardware",
"Memoria RAM",
"Almacenamiento",
"Pantalla",
"Teclado",
"Batería",
"Alimentación",
"Compra y garantía",
"Soporte y reparación",
]
);
const names = updated.values.data.fields.map(
(field) =>
after.definitions.find((item) => item.id.toPrimitive() === field.fieldId)?.values.data.name
);
for (const name of [
"Tarjeta gráfica",
"Webcam",
"Estándar Wi-Fi",
"Versión de Bluetooth",
"Teclado numérico",
]) {
assert.ok(names.includes(name));
}
for (const name of [
"Sistema operativo",
"Nombre de equipo",
"Dirección IP",
"Dirección MAC",
"Conexiones",
]) {
assert.equal(names.includes(name), false);
}
for (const binding of updated.bindings) {
const previous = oldLaptop.bindings.find((item) => item.fieldId.equals(binding.fieldId));
if (previous) {
assert.ok(previous.id.equals(binding.id));
}
}
assert.equal(
updated.values.data.sections?.find((section) => section.name === "Hardware")?.id,
sections.find((section) => section.name === "Hardware")?.id
);
assert.equal(
updated.values.data.sections?.find((section) => section.name === "Pantalla")?.id,
sections.find((section) => section.name === "Pantalla y conexiones")?.id
);
assert.equal(
after.definitions.find((item) => item.id.equals(copy.id)),
copy
);
const desktop = before.definitions.find(
(item) => item.stockKey.getOrUndefined() === "template:desktop"
);
assert.equal(
after.definitions.find((item) => item.stockKey.getOrUndefined() === "template:desktop"),
desktop
);
assert.equal(oldLaptop.values, oldValues);
assert.equal(updated.values.data.fields.length, 39);
const batteryState = after.definitions.find(
(item) => item.stockKey.getOrUndefined() === "field:battery_status"
);
assert.ok(batteryState?.values.data.kind === "field");
assert.equal(batteryState.values.data.type, "choice");
assert.deepEqual(batteryState.values.data.choices, [
"Normal",
"Desgastada",
"Averiada",
"Hinchada",
]);
for (const key of ["memory", "storage", "numeric_keypad"]) {
const field = after.definitions.find(
(item) => item.stockKey.getOrUndefined() === `field:${key}`
);
assert.ok(field);
assert.equal(
after.definitions.filter((item) => item.stockKey.getOrUndefined() === `field:${key}`).length,
1
);
}
assert.ok(updated.values.data.fields.every((field) => !field.required));
assert.equal(seedAssetCatalog(after), after);
});
test("plantillas globales de serie editables: conserva identidad, asociaciones, disponibilidad y cambios al reiniciar", () => {
const initial = seedAssetCatalog(AssetCatalog.create());
const template = initial.definitions.find((item) => item.values.data.kind === "template");
assert.ok(template?.values.data.kind === "template");
const original = template.values.data;
const input = values({
...original,
name: "Plantilla revisada",
description: "Personalizada",
sections: [],
fields: original.fields
.slice(0, 1)
.map((field) => ({ fieldId: field.fieldId, required: true })),
});
const result = initial.save(Maybe.none(), Maybe.some(template.id), input);
assert.ok(result.isSuccess);
const edited = result.data.definitions.find((item) => item.id.equals(template.id));
assert.ok(edited?.values.data.kind === "template");
assert.equal(edited.values.data.name, "Plantilla revisada");
assert.equal(edited.values.data.fields[0]?.required, true);
assert.ok(edited.bindings[0]?.id.equals(template.bindings[0]?.id));
assert.equal(edited.stockKey.getOrUndefined(), template.stockKey.getOrUndefined());
assert.equal(edited.active, template.active);
assert.equal(seedAssetCatalog(result.data), result.data);
assert.deepEqual(template.values.data, original);
const repeated = result.data.save(Maybe.none(), Maybe.some(template.id), input);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, result.data);
const crossScope = result.data.save(
Maybe.some(UniqueID.generateNewID()),
Maybe.some(template.id),
input
);
assert.ok(!crossScope.isSuccess);
const protectedField = initial.definitions.find((item) => item.values.data.kind === "field");
assert.ok(protectedField);
const fieldChange = initial.save(
Maybe.none(),
Maybe.some(protectedField.id),
values({ ...protectedField.values.data, name: "Campo revisado" })
);
assert.ok(fieldChange.isSuccess);
const fieldEdited = fieldChange.data.definitions.find((item) =>
item.id.equals(protectedField.id)
);
assert.equal(fieldEdited?.values.data.name, "Campo revisado");
assert.equal(seedAssetCatalog(fieldChange.data), fieldChange.data);
});
test("campos globales de serie editables: listas ampliables, tipo fijo y opciones existentes conservadas", () => {
const catalog = seedAssetCatalog(AssetCatalog.create());
const field = catalog.definitions.find(
(item) => item.values.data.kind === "field" && item.values.data.type === "choice"
);
assert.ok(field?.values.data.kind === "field");
const previous = field.values.data;
const added = values({
...previous,
name: "Lista revisada",
description: "Personalizada",
unit: "unidad",
choices: [...previous.choices, "Nueva opción"],
});
const result = catalog.save(Maybe.none(), Maybe.some(field.id), added);
assert.ok(result.isSuccess);
const edited = result.data.definitions.find((item) => item.id.equals(field.id));
assert.ok(edited);
assert.deepEqual(edited.values.data, added.data);
assert.equal(seedAssetCatalog(result.data), result.data);
assert.deepEqual(field.values.data, previous);
const differentType = result.data.save(
Maybe.none(),
Maybe.some(field.id),
values({ ...previous, type: "text", choices: [] })
);
assert.ok(!differentType.isSuccess);
assert.equal(differentType.error.code, "FIELD_TYPE_FIXED");
const removedOption = result.data.save(Maybe.none(), Maybe.some(field.id), values(previous));
assert.ok(!removedOption.isSuccess);
assert.equal(removedOption.error.code, "FIELD_TYPE_FIXED");
});

View File

@ -84,7 +84,7 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
} }
update(values: AssetDefinitionValues): Result<AssetDefinition, AssetCatalogError> { update(values: AssetDefinitionValues): Result<AssetDefinition, AssetCatalogError> {
if (this.stockKey.getOrUndefined()) { if (this.stockKey.getOrUndefined() && this.values.data.kind === "relation") {
return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED")); return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED"));
} }
const previous = this.values.data; const previous = this.values.data;
@ -177,6 +177,81 @@ export class AssetDefinition extends DomainEntity<DefinitionProps> {
return new AssetDefinition({ ...this.props, values: values.data }, this.id); return new AssetDefinition({ ...this.props, values: values.data }, this.id);
} }
/** Revisión explícita de una plantilla de serie; no altera las instantáneas de activos. */
refreshBuiltinTemplate(source: AssetDefinitionValues) {
const previous = this.values.data;
const next = source.data;
if (
!this.stockKey.getOrUndefined() ||
previous.kind !== "template" ||
next.kind !== "template"
) {
return this;
}
// Reservar primero los grupos que mantienen nombre evita reutilizar su UUID al dividir un grupo.
const retainedSectionIds = new Set(
(previous.sections ?? [])
.filter((section) => next.sections?.some((candidate) => candidate.name === section.name))
.map((section) => section.id)
);
const sections = (next.sections ?? []).map((section) => {
const matching =
previous.sections?.find((entry) => entry.name === section.name) ??
previous.sections?.find(
(entry) =>
!retainedSectionIds.has(entry.id) &&
previous.fields.some(
(field) =>
field.sectionId === entry.id &&
next.fields.some(
(candidate) =>
candidate.sectionId === section.id && candidate.fieldId === field.fieldId
)
)
);
const id = matching?.id ?? section.id;
retainedSectionIds.add(id);
return { ...section, id };
});
const fields = next.fields.map((field) => {
const sectionIndex =
next.sections?.findIndex((section) => section.id === field.sectionId) ?? -1;
const sectionId = sections[sectionIndex]?.id;
return {
fieldId: field.fieldId,
required: field.required,
...(sectionId ? { sectionId } : {}),
};
});
const values = AssetDefinitionValues.create({
...previous,
icon: previous.icon ?? next.icon,
fields,
sections,
});
if (!values.isSuccess) {
throw new Error("Revisión de plantilla de serie inconsistente.");
}
if (this.values.equals(values.data)) {
return this;
}
const bindings = values.data.fieldIds.map((fieldId, index) =>
TemplateField.create(
fieldId,
fields[index]?.required ?? false,
this.bindings.find((binding) => binding.fieldId.equals(fieldId))?.id
)
);
return new AssetDefinition({ ...this.props, values: values.data, bindings }, this.id);
}
setActive(active: boolean) { setActive(active: boolean) {
return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id); return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id);
} }

View File

@ -156,7 +156,7 @@ test("secciones: plantillas globales y locales conservan separación y UUID al e
assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía
}); });
test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni referencias", () => { test("secciones de serie: el arranque conserva una plantilla sin grupos y su disponibilidad", () => {
const seeded = seedAssetCatalog(AssetCatalog.create()); const seeded = seedAssetCatalog(AssetCatalog.create());
const grouped = seeded.definitions.find( const grouped = seeded.definitions.find(
(item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0
@ -202,7 +202,8 @@ test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni
next.bindings.map((item) => item.id.toPrimitive()), next.bindings.map((item) => item.id.toPrimitive()),
old.bindings.map((item) => item.id.toPrimitive()) old.bindings.map((item) => item.id.toPrimitive())
); );
assert.ok(next.sections.length > 0); assert.equal(next.sections.length, 0);
assert.equal(next, old);
assert.equal(seedAssetCatalog(upgraded), upgraded); assert.equal(seedAssetCatalog(upgraded), upgraded);
assert.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7")); assert.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7"));
}); });

View File

@ -242,7 +242,9 @@
"optional": "Optional", "optional": "Optional",
"remove": "Remove field: {{name}}", "remove": "Remove field: {{name}}",
"noFields": "Add at least one field. All fields are initially optional.", "noFields": "Add at least one field. All fields are initially optional.",
"protected": "Built-in definitions are protected. You can change their availability or create a template copy.", "fieldHelpText": "Help text (optional)",
"fieldHelpHint": "Shown next to the field when creating or editing an asset.",
"protected": "Built-in templates and fields can be edited in the global catalog. A field's data type remains fixed. You can change availability or create a template copy for a company.",
"helpTitle": "What is it for?", "helpTitle": "What is it for?",
"examplesLabel": "Show examples and guidance", "examplesLabel": "Show examples and guidance",
"fieldDescription": "A field describes an asset characteristic. You can reuse it in several templates.", "fieldDescription": "A field describes an asset characteristic. You can reuse it in several templates.",
@ -264,7 +266,7 @@
"NAME_EXISTS": "A definition of this type already has this name.", "NAME_EXISTS": "A definition of this type already has this name.",
"FIELD_UNAVAILABLE": "Only active global or company fields can be added.", "FIELD_UNAVAILABLE": "Only active global or company fields can be added.",
"FIELD_TYPE_FIXED": "The data type cannot change and existing list values cannot be removed.", "FIELD_TYPE_FIXED": "The data type cannot change and existing list values cannot be removed.",
"BUILTIN_PROTECTED": "Built-in definitions cannot be edited.", "BUILTIN_PROTECTED": "Built-in relationship types cannot be edited.",
"DEFINITION_NOT_FOUND": "This definition is unavailable in this scope.", "DEFINITION_NOT_FOUND": "This definition is unavailable in this scope.",
"COMPANY_NOT_FOUND": "Company not found.", "COMPANY_NOT_FOUND": "Company not found.",
"INTERNAL_ERROR": "The operation could not be completed.", "INTERNAL_ERROR": "The operation could not be completed.",

View File

@ -242,7 +242,9 @@
"optional": "Opcional", "optional": "Opcional",
"remove": "Retirar campo: {{name}}", "remove": "Retirar campo: {{name}}",
"noFields": "Añade al menos un campo. Todos son opcionales inicialmente.", "noFields": "Añade al menos un campo. Todos son opcionales inicialmente.",
"protected": "Las definiciones de serie están protegidas. Puedes cambiar su disponibilidad o crear una copia de una plantilla.", "fieldHelpText": "Texto de ayuda (opcional)",
"fieldHelpHint": "Se muestra junto al campo al crear o editar un activo.",
"protected": "Las plantillas y campos de serie se pueden editar desde el catálogo global. El tipo de dato de un campo se mantiene fijo. Puedes cambiar la disponibilidad o crear una copia de una plantilla para una compañía.",
"helpTitle": "¿Qué es y para qué sirve?", "helpTitle": "¿Qué es y para qué sirve?",
"examplesLabel": "Ver ejemplos y recomendaciones", "examplesLabel": "Ver ejemplos y recomendaciones",
"fieldDescription": "Un campo describe una característica que podrás rellenar en un activo. Puedes reutilizarlo en varias plantillas.", "fieldDescription": "Un campo describe una característica que podrás rellenar en un activo. Puedes reutilizarlo en varias plantillas.",
@ -264,7 +266,7 @@
"NAME_EXISTS": "Ya existe una definición de este tipo con ese nombre.", "NAME_EXISTS": "Ya existe una definición de este tipo con ese nombre.",
"FIELD_UNAVAILABLE": "Solo puedes añadir campos activos del catálogo global o de esta compañía.", "FIELD_UNAVAILABLE": "Solo puedes añadir campos activos del catálogo global o de esta compañía.",
"FIELD_TYPE_FIXED": "El tipo de dato no se puede cambiar ni se pueden eliminar valores existentes de una lista.", "FIELD_TYPE_FIXED": "El tipo de dato no se puede cambiar ni se pueden eliminar valores existentes de una lista.",
"BUILTIN_PROTECTED": "Las definiciones de serie no se pueden modificar.", "BUILTIN_PROTECTED": "Los tipos de relación de serie no se pueden modificar.",
"DEFINITION_NOT_FOUND": "La definición no está disponible en este ámbito.", "DEFINITION_NOT_FOUND": "La definición no está disponible en este ámbito.",
"COMPANY_NOT_FOUND": "No se ha encontrado la compañía.", "COMPANY_NOT_FOUND": "No se ha encontrado la compañía.",
"INTERNAL_ERROR": "No se ha podido completar la operación.", "INTERNAL_ERROR": "No se ha podido completar la operación.",

View File

@ -1,3 +1,4 @@
import { FieldHelp, fieldHelpId } from "@repo/rdx-ui/components/field-help";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Input } from "@repo/shadcn-ui/components/input"; import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label"; import { Label } from "@repo/shadcn-ui/components/label";
@ -13,15 +14,18 @@ export function AssetCharacteristicControl({
onChange, onChange,
prefix, prefix,
disabled, disabled,
helpText,
}: { }: {
field: AssetCharacteristic; field: AssetCharacteristic;
value: CharacteristicValue | undefined; value: CharacteristicValue | undefined;
onChange: (value: CharacteristicValue | undefined) => void; onChange: (value: CharacteristicValue | undefined) => void;
prefix: string; prefix: string;
disabled: boolean; disabled: boolean;
helpText?: string | undefined;
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const id = `${prefix}-${field.fieldId}`; const id = `${prefix}-${field.fieldId}`;
const describedBy = fieldHelpId(id, helpText);
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`; const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`;
return ( return (
@ -29,6 +33,7 @@ export function AssetCharacteristicControl({
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
{field.type === "choice" || field.type === "boolean" ? ( {field.type === "choice" || field.type === "boolean" ? (
<SelectWithAction <SelectWithAction
describedBy={describedBy}
disabled={disabled} disabled={disabled}
id={id} id={id}
label={label} label={label}
@ -51,6 +56,7 @@ export function AssetCharacteristicControl({
/> />
) : ( ) : (
<Input <Input
aria-describedby={describedBy}
disabled={disabled} disabled={disabled}
id={id} id={id}
maxLength={10000} maxLength={10000}
@ -74,6 +80,7 @@ export function AssetCharacteristicControl({
value={value === undefined ? "" : String(value)} value={value === undefined ? "" : String(value)}
/> />
)} )}
<FieldHelp id={describedBy} text={helpText} />
</div> </div>
); );
} }

View File

@ -1,12 +1,14 @@
import { ContextHelp } from "@repo/rdx-ui/components/context-help"; import { ContextHelp } from "@repo/rdx-ui/components/context-help";
import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormSection } from "@repo/rdx-ui/components/form-section"; import { FormSection } from "@repo/rdx-ui/components/form-section";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { TabbedFieldSections } from "@repo/rdx-ui/components/tabbed-field-sections";
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input"; import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label"; import { Label } from "@repo/shadcn-ui/components/label";
import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { Textarea } from "@repo/shadcn-ui/components/textarea";
import type { FormEvent } from "react";
import { useId } from "react"; import { useId } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -42,17 +44,49 @@ export function AssetForm(props: AssetFormProps) {
const prefix = useId(); const prefix = useId();
const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") }; const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") };
const submitWithTabs = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const invalid = event.currentTarget.querySelector<
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
>("input:invalid, select:invalid, textarea:invalid");
if (invalid) {
invalid.reportValidity();
return;
}
await submit(event);
const invalidGeneralField = (
["name", "templateId", "status", "siteId", "notes"] satisfies (keyof typeof values)[]
).find((name) => form.getFieldState(name).error);
if (invalidGeneralField) {
form.setFocus(invalidGeneralField);
}
};
const fieldHelp = fieldHelpById(items);
return ( return (
<> <>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}> <form
className="flex min-h-0 flex-1 flex-col overflow-hidden"
noValidate
onSubmit={submitWithTabs}
>
<FormBody> <FormBody>
<fieldset className="space-y-5" disabled={mutation.isPending}> <fieldset className="space-y-5" disabled={mutation.isPending}>
<FormSection title={t("formLayout.identity")}> <FormSection title={t("formLayout.identity")} variant="plain">
<div className="space-y-2"> <div className="grid gap-x-5 gap-y-4 @min-[560px]:grid-cols-2 @min-[1100px]:grid-cols-4">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label> <div className="space-y-2">
<Input id={`${prefix}-name`} maxLength={100} required {...form.register("name")} /> <Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
</div> <Input
<div className="grid gap-4 sm:grid-cols-2"> id={`${prefix}-name`}
maxLength={100}
required
{...form.register("name")}
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`${prefix}-template`}>{t("template")}</Label> <Label htmlFor={`${prefix}-template`}>{t("template")}</Label>
<SelectWithAction <SelectWithAction
@ -87,7 +121,10 @@ export function AssetForm(props: AssetFormProps) {
id={`${prefix}-status`} id={`${prefix}-status`}
label={t("status")} label={t("status")}
onValueChange={changeStatus} onValueChange={changeStatus}
options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))} options={AssetStatusSchema.options.map((value) => ({
value,
label: t(value),
}))}
placeholder={t("chooseValue")} placeholder={t("chooseValue")}
required required
value={values.status} value={values.status}
@ -108,24 +145,28 @@ export function AssetForm(props: AssetFormProps) {
id={`${prefix}-site`} id={`${prefix}-site`}
label={t("site")} label={t("site")}
onValueChange={(id) => form.setValue("siteId", id || null)} onValueChange={(id) => form.setValue("siteId", id || null)}
options={lookups.sites.map((site) => ({ value: site.id, label: site.name }))} options={lookups.sites.map((site) => ({
value: site.id,
label: site.name,
}))}
value={values.siteId ?? ""} value={values.siteId ?? ""}
/> />
</div> </div>
</div> </div>
</FormSection> </FormSection>
{fields.length > 0 && ( {fields.length > 0 && (
<section className="space-y-3"> <FormSection title={t("characteristics")} variant="plain">
<h3 className="font-medium">{t("characteristics")}</h3> <TabbedFieldSections
<FieldSections
hasErrors={!!mutation.error || Object.keys(form.formState.errors).length > 0}
items={fields} items={fields}
key={values.templateId}
label={t("characteristics")}
renderItem={(field) => renderItem={(field) =>
field.fieldId === brandFieldId ? ( field.fieldId === brandFieldId ? (
<BrandSelect <BrandSelect
allowQuickCreate={allowQuickCreate} allowQuickCreate={allowQuickCreate}
brandId={values.brandId ?? null} brandId={values.brandId ?? null}
disabled={mutation.isPending} disabled={mutation.isPending}
helpText={fieldHelp.get(field.fieldId)}
id={`${prefix}-${field.fieldId}`} id={`${prefix}-${field.fieldId}`}
key={field.fieldId} key={field.fieldId}
label={field.name} label={field.name}
@ -144,6 +185,7 @@ export function AssetForm(props: AssetFormProps) {
<AssetCharacteristicControl <AssetCharacteristicControl
disabled={mutation.isPending} disabled={mutation.isPending}
field={field} field={field}
helpText={fieldHelp.get(field.fieldId)}
key={field.fieldId} key={field.fieldId}
onChange={(value) => setCharacteristic(field.fieldId, value)} onChange={(value) => setCharacteristic(field.fieldId, value)}
prefix={prefix} prefix={prefix}
@ -154,7 +196,7 @@ export function AssetForm(props: AssetFormProps) {
sections={sections} sections={sections}
ungroupedLabel={t("sections.ungrouped")} ungroupedLabel={t("sections.ungrouped")}
/> />
</section> </FormSection>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label> <Label htmlFor={`${prefix}-notes`}>{t("notes")}</Label>
@ -179,6 +221,7 @@ export function AssetForm(props: AssetFormProps) {
guidance={t("assetGuidance")} guidance={t("assetGuidance")}
scopeNote={t("assetScope")} scopeNote={t("assetScope")}
title={t("helpTitle")} title={t("helpTitle")}
variant="plain"
/> />
</FormBody> </FormBody>
<FormActions> <FormActions>

View File

@ -3,6 +3,7 @@ import type { BrandDTO } from "../../../../common/brands";
export type BrandSelectProps = { export type BrandSelectProps = {
id: string; id: string;
label: string; label: string;
helpText?: string | undefined;
brandId: string | null; brandId: string | null;
legacyName: string; legacyName: string;
disabled: boolean; disabled: boolean;

View File

@ -1,3 +1,4 @@
import { FieldHelp, fieldHelpId } from "@repo/rdx-ui/components/field-help";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { Label } from "@repo/shadcn-ui/components/label"; import { Label } from "@repo/shadcn-ui/components/label";
@ -14,6 +15,7 @@ export function BrandSelect(props: BrandSelectProps) {
const { id, label, brandId, legacyName, disabled, required, allowQuickCreate, onChange } = props; const { id, label, brandId, legacyName, disabled, required, allowQuickCreate, onChange } = props;
const { query, open, setOpen, brands, legacy, options } = useBrandSelectController(props); const { query, open, setOpen, brands, legacy, options } = useBrandSelectController(props);
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const describedBy = fieldHelpId(id, props.helpText);
if (legacy) { if (legacy) {
options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.legacy")})` }); options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.legacy")})` });
@ -29,6 +31,7 @@ export function BrandSelect(props: BrandSelectProps) {
action={ action={
allowQuickCreate ? { label: t("brands.new"), onClick: () => setOpen(true) } : undefined allowQuickCreate ? { label: t("brands.new"), onClick: () => setOpen(true) } : undefined
} }
describedBy={describedBy}
disabled={disabled || query.isPending || query.isError} disabled={disabled || query.isPending || query.isError}
id={id} id={id}
label={label} label={label}
@ -47,6 +50,7 @@ export function BrandSelect(props: BrandSelectProps) {
required={required} required={required}
value={brandId ?? (legacy ? LEGACY : "")} value={brandId ?? (legacy ? LEGACY : "")}
/> />
<FieldHelp id={describedBy} text={props.helpText} />
{query.isError && ( {query.isError && (
<div role="alert"> <div role="alert">
<p className="text-sm text-destructive">{t("brands.loadError")}</p> <p className="text-sm text-destructive">{t("brands.loadError")}</p>

View File

@ -57,13 +57,17 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label> <Label htmlFor={`${id}-description`}>{t("fieldHelpText")}</Label>
<Textarea <Textarea
aria-describedby={`${id}-description-hint`}
id={`${id}-description`} id={`${id}-description`}
maxLength={1000} maxLength={1000}
rows={3} rows={3}
{...form.register("description")} {...form.register("description")}
/> />
<p className="text-xs text-muted-foreground" id={`${id}-description-hint`}>
{t("fieldHelpHint")}
</p>
</div> </div>
<CatalogError <CatalogError
error={mutation.error} error={mutation.error}

View File

@ -29,7 +29,9 @@ export function CatalogCell({
onStatus: (active: boolean | null) => void; onStatus: (active: boolean | null) => void;
}) { }) {
const { t } = useTranslation("assets"); const { t } = useTranslation("assets");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope); const editable =
(!item.builtin || item.values.kind === "template" || item.values.kind === "field") &&
item.companyId === (scope === "global" ? null : scope);
switch (column) { switch (column) {
case "name": case "name":

View File

@ -144,23 +144,7 @@ export class ResourceCatalog extends AggregateRoot<CatalogProps> {
const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey);
if (previous) { if (previous) {
const upgraded = previous.initializeSections(values); return Result.ok(this);
if (upgraded === previous) {
return Result.ok(this);
}
return Result.ok(
new ResourceCatalog(
{
...this.props,
definitions: this.definitions.map((item) =>
item.id.equals(previous.id) ? upgraded : item
),
},
this.id
)
);
} }
return this.replace( return this.replace(

View File

@ -43,8 +43,11 @@ test("catálogo de recursos: serie idempotente, UUID v7, características opcion
values({ ...template.values.data, name: "Cambio" }) values({ ...template.values.data, name: "Cambio" })
); );
assert.ok(!changed.isSuccess); assert.ok(changed.isSuccess);
assert.equal(changed.error.code, "BUILTIN_PROTECTED"); const edited = changed.data.definitions.find((item) => item.id.equals(template.id));
assert.equal(edited?.values.data.name, "Cambio");
assert.equal(seedResourceCatalog(changed.data), changed.data);
const disabled = catalog.setActive(Maybe.none(), template.id, false); const disabled = catalog.setActive(Maybe.none(), template.id, false);
assert.ok(disabled.isSuccess); assert.ok(disabled.isSuccess);
@ -183,10 +186,10 @@ test("licencias: ampliar un catálogo previo conserva identidades y disponibilid
assert.ok(license && license.values.data.kind === "template"); assert.ok(license && license.values.data.kind === "template");
assert.ok(license.values.data.fields.every((binding) => !binding.required)); assert.ok(license.values.data.fields.every((binding) => !binding.required));
const protectedTemplate = license.update(values({ ...license.values.data, name: "Otra" })); const editedTemplate = license.update(values({ ...license.values.data, name: "Otra" }));
assert.ok(!protectedTemplate.isSuccess); assert.ok(editedTemplate.isSuccess);
assert.equal(protectedTemplate.error.code, "BUILTIN_PROTECTED"); assert.ok(editedTemplate.data.id.equals(license.id));
const company = UniqueID.generateNewID(); const company = UniqueID.generateNewID();
const locallyDisabled = upgraded.setActive(Maybe.some(company), license.id, false); const locallyDisabled = upgraded.setActive(Maybe.some(company), license.id, false);
@ -195,3 +198,98 @@ test("licencias: ampliar un catálogo previo conserva identidades y disponibilid
assert.equal(locallyDisabled.data.active(license, Maybe.none()), true); assert.equal(locallyDisabled.data.active(license, Maybe.none()), true);
assert.equal(seedResourceCatalog(locallyDisabled.data), locallyDisabled.data); assert.equal(seedResourceCatalog(locallyDisabled.data), locallyDisabled.data);
}); });
test("plantillas globales de serie editables: conserva identidad, asociaciones, disponibilidad y cambios al reiniciar", () => {
const initial = seedResourceCatalog(ResourceCatalog.create());
const template = initial.definitions.find((item) => item.values.data.kind === "template");
assert.ok(template?.values.data.kind === "template");
const original = template.values.data;
const input = values({
...original,
name: "Plantilla revisada",
description: "Personalizada",
sections: [],
fields: original.fields
.slice(0, 1)
.map((field) => ({ fieldId: field.fieldId, required: true })),
});
const result = initial.save(Maybe.none(), Maybe.some(template.id), input);
assert.ok(result.isSuccess);
const edited = result.data.definitions.find((item) => item.id.equals(template.id));
assert.ok(edited?.values.data.kind === "template");
assert.equal(edited.values.data.name, "Plantilla revisada");
assert.equal(edited.values.data.fields[0]?.required, true);
assert.ok(edited.bindings[0]?.id.equals(template.bindings[0]?.id));
assert.equal(edited.stockKey.getOrUndefined(), template.stockKey.getOrUndefined());
assert.equal(edited.active, template.active);
assert.equal(seedResourceCatalog(result.data), result.data);
assert.deepEqual(template.values.data, original);
const repeated = result.data.save(Maybe.none(), Maybe.some(template.id), input);
assert.ok(repeated.isSuccess);
assert.equal(repeated.data, result.data);
const crossScope = result.data.save(
Maybe.some(UniqueID.generateNewID()),
Maybe.some(template.id),
input
);
assert.ok(!crossScope.isSuccess);
const protectedField = initial.definitions.find((item) => item.values.data.kind === "field");
assert.ok(protectedField);
const fieldChange = initial.save(
Maybe.none(),
Maybe.some(protectedField.id),
values({ ...protectedField.values.data, name: "Campo revisado" })
);
assert.ok(fieldChange.isSuccess);
const fieldEdited = fieldChange.data.definitions.find((item) =>
item.id.equals(protectedField.id)
);
assert.equal(fieldEdited?.values.data.name, "Campo revisado");
assert.equal(seedResourceCatalog(fieldChange.data), fieldChange.data);
});
test("campos globales de serie editables: listas ampliables, tipo fijo y opciones existentes conservadas", () => {
const catalog = seedResourceCatalog(ResourceCatalog.create());
const field = catalog.definitions.find(
(item) => item.values.data.kind === "field" && item.values.data.type === "choice"
);
assert.ok(field?.values.data.kind === "field");
const previous = field.values.data;
const added = values({
...previous,
name: "Lista revisada",
description: "Personalizada",
unit: "unidad",
choices: [...previous.choices, "Nueva opción"],
});
const result = catalog.save(Maybe.none(), Maybe.some(field.id), added);
assert.ok(result.isSuccess);
const edited = result.data.definitions.find((item) => item.id.equals(field.id));
assert.ok(edited);
assert.deepEqual(edited.values.data, added.data);
assert.equal(seedResourceCatalog(result.data), result.data);
assert.deepEqual(field.values.data, previous);
const differentType = result.data.save(
Maybe.none(),
Maybe.some(field.id),
values({ ...previous, type: "text", choices: [] })
);
assert.ok(!differentType.isSuccess);
assert.equal(differentType.error.code, "FIELD_TYPE_FIXED");
const removedOption = result.data.save(Maybe.none(), Maybe.some(field.id), values(previous));
assert.ok(!removedOption.isSuccess);
assert.equal(removedOption.error.code, "FIELD_TYPE_FIXED");
});

View File

@ -84,9 +84,6 @@ export class ResourceDefinition extends DomainEntity<DefinitionProps> {
} }
update(values: ResourceDefinitionValues): Result<ResourceDefinition, ResourceCatalogError> { update(values: ResourceDefinitionValues): Result<ResourceDefinition, ResourceCatalogError> {
if (this.stockKey.getOrUndefined()) {
return Result.fail(new ResourceCatalogError("BUILTIN_PROTECTED"));
}
const previous = this.values.data; const previous = this.values.data;
const next = values.data; const next = values.data;

View File

@ -156,7 +156,7 @@ test("secciones: plantillas globales y locales conservan separación y UUID al e
assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía assert.ok(!unrelated.isSuccess); // no admite campos de otro catálogo o compañía
}); });
test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni referencias", () => { test("secciones de serie: el arranque conserva una plantilla sin grupos y su disponibilidad", () => {
const seeded = seedResourceCatalog(ResourceCatalog.create()); const seeded = seedResourceCatalog(ResourceCatalog.create());
const grouped = seeded.definitions.find( const grouped = seeded.definitions.find(
(item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0 (item) => item.values.data.kind === "template" && (item.values.data.sections?.length ?? 0) > 0
@ -202,7 +202,8 @@ test("secciones de serie: ampliación idempotente sin cambiar disponibilidad ni
next.bindings.map((item) => item.id.toPrimitive()), next.bindings.map((item) => item.id.toPrimitive()),
old.bindings.map((item) => item.id.toPrimitive()) old.bindings.map((item) => item.id.toPrimitive())
); );
assert.ok(next.sections.length > 0); assert.equal(next.sections.length, 0);
assert.equal(next, old);
assert.equal(seedResourceCatalog(upgraded), upgraded); assert.equal(seedResourceCatalog(upgraded), upgraded);
assert.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7")); assert.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7"));
}); });

View File

@ -110,7 +110,9 @@
"optional": "Optional", "optional": "Optional",
"remove": "Remove field: {{name}}", "remove": "Remove field: {{name}}",
"noFields": "Add at least one field. All fields are initially optional.", "noFields": "Add at least one field. All fields are initially optional.",
"protected": "Built-in definitions are protected. You can change their availability or create a template copy.", "fieldHelpText": "Help text (optional)",
"fieldHelpHint": "Shown next to the field when creating or editing a resource.",
"protected": "Built-in templates and fields can be edited in the global catalog. A field's data type remains fixed. You can change availability or create a template copy for a company.",
"helpTitle": "What is it for?", "helpTitle": "What is it for?",
"examplesLabel": "Show examples and guidance", "examplesLabel": "Show examples and guidance",
"fieldDescription": "A field describes an resource characteristic. You can reuse it in several templates.", "fieldDescription": "A field describes an resource characteristic. You can reuse it in several templates.",
@ -131,7 +133,7 @@
"NAME_EXISTS": "A definition of this type already has this name.", "NAME_EXISTS": "A definition of this type already has this name.",
"FIELD_UNAVAILABLE": "Only active global or company fields can be added.", "FIELD_UNAVAILABLE": "Only active global or company fields can be added.",
"FIELD_TYPE_FIXED": "The data type and template use cannot change; existing list values cannot be removed.", "FIELD_TYPE_FIXED": "The data type and template use cannot change; existing list values cannot be removed.",
"BUILTIN_PROTECTED": "Built-in definitions cannot be edited.", "BUILTIN_PROTECTED": "Built-in fields cannot be edited.",
"DEFINITION_NOT_FOUND": "This definition is unavailable in this scope.", "DEFINITION_NOT_FOUND": "This definition is unavailable in this scope.",
"COMPANY_NOT_FOUND": "Company not found.", "COMPANY_NOT_FOUND": "Company not found.",
"INTERNAL_ERROR": "The operation could not be completed.", "INTERNAL_ERROR": "The operation could not be completed.",

View File

@ -110,7 +110,9 @@
"optional": "Opcional", "optional": "Opcional",
"remove": "Retirar campo: {{name}}", "remove": "Retirar campo: {{name}}",
"noFields": "Añade al menos un campo. Todos son opcionales inicialmente.", "noFields": "Añade al menos un campo. Todos son opcionales inicialmente.",
"protected": "Las definiciones de serie están protegidas. Puedes cambiar su disponibilidad o crear una copia de una plantilla.", "fieldHelpText": "Texto de ayuda (opcional)",
"fieldHelpHint": "Se muestra junto al campo al crear o editar un recurso.",
"protected": "Las plantillas y campos de serie se pueden editar desde el catálogo global. El tipo de dato de un campo se mantiene fijo. Puedes cambiar la disponibilidad o crear una copia de una plantilla para una compañía.",
"helpTitle": "¿Qué es y para qué sirve?", "helpTitle": "¿Qué es y para qué sirve?",
"examplesLabel": "Ver ejemplos y recomendaciones", "examplesLabel": "Ver ejemplos y recomendaciones",
"fieldDescription": "Un campo describe una característica que podrás rellenar en un recurso. Puedes reutilizarlo en varias plantillas.", "fieldDescription": "Un campo describe una característica que podrás rellenar en un recurso. Puedes reutilizarlo en varias plantillas.",
@ -131,7 +133,7 @@
"NAME_EXISTS": "Ya existe una definición de este tipo con ese nombre.", "NAME_EXISTS": "Ya existe una definición de este tipo con ese nombre.",
"FIELD_UNAVAILABLE": "Solo puedes añadir campos activos de recursos del catálogo global o de esta compañía.", "FIELD_UNAVAILABLE": "Solo puedes añadir campos activos de recursos del catálogo global o de esta compañía.",
"FIELD_TYPE_FIXED": "El tipo de dato y el uso de la plantilla quedan fijos; tampoco se pueden eliminar valores existentes de una lista.", "FIELD_TYPE_FIXED": "El tipo de dato y el uso de la plantilla quedan fijos; tampoco se pueden eliminar valores existentes de una lista.",
"BUILTIN_PROTECTED": "Las definiciones de serie no se pueden modificar.", "BUILTIN_PROTECTED": "Los campos de serie no se pueden modificar.",
"DEFINITION_NOT_FOUND": "La definición no está disponible en este ámbito.", "DEFINITION_NOT_FOUND": "La definición no está disponible en este ámbito.",
"COMPANY_NOT_FOUND": "No se ha encontrado la compañía.", "COMPANY_NOT_FOUND": "No se ha encontrado la compañía.",
"INTERNAL_ERROR": "No se ha podido completar la operación.", "INTERNAL_ERROR": "No se ha podido completar la operación.",

View File

@ -57,13 +57,17 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
</div> </div>
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`${id}-description`}>{t("description")}</Label> <Label htmlFor={`${id}-description`}>{t("fieldHelpText")}</Label>
<Textarea <Textarea
aria-describedby={`${id}-description-hint`}
id={`${id}-description`} id={`${id}-description`}
maxLength={1000} maxLength={1000}
rows={3} rows={3}
{...form.register("description")} {...form.register("description")}
/> />
<p className="text-xs text-muted-foreground" id={`${id}-description-hint`}>
{t("fieldHelpHint")}
</p>
</div> </div>
<CatalogError <CatalogError
error={mutation.error} error={mutation.error}

View File

@ -28,7 +28,7 @@ export function CatalogCell({
onStatus: (active: boolean | null) => void; onStatus: (active: boolean | null) => void;
}) { }) {
const { t } = useTranslation("resources"); const { t } = useTranslation("resources");
const editable = !item.builtin && item.companyId === (scope === "global" ? null : scope); const editable = item.companyId === (scope === "global" ? null : scope);
const values = item.values; const values = item.values;
switch (column) { switch (column) {

View File

@ -1,3 +1,4 @@
import { FieldHelp, fieldHelpId } from "@repo/rdx-ui/components/field-help";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Input } from "@repo/shadcn-ui/components/input"; import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label"; import { Label } from "@repo/shadcn-ui/components/label";
@ -13,15 +14,18 @@ export function ResourceCharacteristicControl({
onChange, onChange,
prefix, prefix,
disabled, disabled,
helpText,
}: { }: {
field: ResourceCharacteristic; field: ResourceCharacteristic;
value: CharacteristicValue | undefined; value: CharacteristicValue | undefined;
onChange: (value: CharacteristicValue | undefined) => void; onChange: (value: CharacteristicValue | undefined) => void;
prefix: string; prefix: string;
disabled: boolean; disabled: boolean;
helpText?: string | undefined;
}) { }) {
const { t } = useTranslation("resources"); const { t } = useTranslation("resources");
const id = `${prefix}-${field.fieldId}`; const id = `${prefix}-${field.fieldId}`;
const describedBy = fieldHelpId(id, helpText);
const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`; const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`;
return ( return (
@ -29,6 +33,7 @@ export function ResourceCharacteristicControl({
<Label htmlFor={id}>{label}</Label> <Label htmlFor={id}>{label}</Label>
{field.type === "choice" || field.type === "boolean" ? ( {field.type === "choice" || field.type === "boolean" ? (
<SelectWithAction <SelectWithAction
describedBy={describedBy}
disabled={disabled} disabled={disabled}
id={id} id={id}
label={label} label={label}
@ -51,6 +56,7 @@ export function ResourceCharacteristicControl({
/> />
) : ( ) : (
<Input <Input
aria-describedby={describedBy}
disabled={disabled} disabled={disabled}
id={id} id={id}
maxLength={10000} maxLength={10000}
@ -74,6 +80,7 @@ export function ResourceCharacteristicControl({
value={value === undefined ? "" : String(value)} value={value === undefined ? "" : String(value)}
/> />
)} )}
<FieldHelp id={describedBy} text={helpText} />
</div> </div>
); );
} }

View File

@ -3,6 +3,7 @@ import { FieldSections } from "@repo/rdx-ui/components/field-sections";
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
import { FormSection } from "@repo/rdx-ui/components/form-section"; import { FormSection } from "@repo/rdx-ui/components/form-section";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { fieldHelpById } from "@repo/rdx-ui/lib/field-help";
import { Button } from "@repo/shadcn-ui/components/button"; import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input"; import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label"; import { Label } from "@repo/shadcn-ui/components/label";
@ -42,6 +43,8 @@ export function ResourceForm(props: ResourceFormProps) {
const { t } = useTranslation("resources"); const { t } = useTranslation("resources");
const prefix = useId(); const prefix = useId();
const fieldHelp = fieldHelpById(items);
return ( return (
<> <>
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}> <form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
@ -115,6 +118,7 @@ export function ResourceForm(props: ResourceFormProps) {
<ResourceCharacteristicControl <ResourceCharacteristicControl
disabled={mutation.isPending} disabled={mutation.isPending}
field={requireMailboxAddress(field, items, values, template)} field={requireMailboxAddress(field, items, values, template)}
helpText={fieldHelp.get(field.fieldId)}
key={field.fieldId} key={field.fieldId}
onChange={(value) => setCharacteristic(field.fieldId, value)} onChange={(value) => setCharacteristic(field.fieldId, value)}
prefix={prefix} prefix={prefix}

View File

@ -0,0 +1,20 @@
import { FieldDescription } from "@repo/shadcn-ui/components/field";
export function fieldHelpId(controlId: string, text: string | undefined) {
return text?.trim() ? `${controlId}-help` : undefined;
}
/** Ayuda discreta y accesible; nunca presenta bloques vacíos. */
export function FieldHelp({ id, text }: { id?: string | undefined; text?: string | undefined }) {
const help = text?.trim();
if (!help) {
return null;
}
return (
<FieldDescription className="text-xs whitespace-pre-line" id={id}>
{help}
</FieldDescription>
);
}

View File

@ -5,6 +5,8 @@ import { ChevronRightIcon } from "../lib/icons";
export type FieldSection = { id: string; name: string; description: string }; export type FieldSection = { id: string; name: string; description: string };
type FieldSectionLayout = "fields" | "rows" | "form";
/** Presentación compartida; no conoce catálogos, valores ni endpoints. */ /** Presentación compartida; no conoce catálogos, valores ni endpoints. */
export function FieldSections<T extends { sectionId?: string | undefined }>({ export function FieldSections<T extends { sectionId?: string | undefined }>({
sections = [], sections = [],
@ -21,9 +23,10 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
renderItem: (item: T) => ReactNode; renderItem: (item: T) => ReactNode;
values?: boolean; values?: boolean;
hasErrors?: boolean; hasErrors?: boolean;
layout?: "fields" | "rows"; layout?: FieldSectionLayout;
}) { }) {
const container = useRef<HTMLDivElement>(null); const container = useRef<HTMLDivElement>(null);
const styles = fieldSectionStyles(layout, values);
useEffect(() => { useEffect(() => {
if (hasErrors) { if (hasErrors) {
@ -37,15 +40,7 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
values ? ( values ? (
<dl className="@container">{fields.map(renderItem)}</dl> <dl className="@container">{fields.map(renderItem)}</dl>
) : ( ) : (
<div <div className={styles.content}>{fields.map(renderItem)}</div>
className={
layout === "rows"
? "divide-y divide-border/50 pl-5"
: "grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3"
}
>
{fields.map(renderItem)}
</div>
); );
if (!sections.length) { if (!sections.length) {
@ -54,32 +49,9 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
const known = new Set(sections.map((section) => section.id)); const known = new Set(sections.map((section) => section.id));
const ungrouped = items.filter((item) => !(item.sectionId && known.has(item.sectionId))); const ungrouped = items.filter((item) => !(item.sectionId && known.has(item.sectionId)));
const sectionClassName =
layout === "rows"
? "@container group/field-section"
: values
? "@container"
: "@container rounded-md border";
const summaryClassName =
layout === "rows"
? "flex cursor-pointer list-none items-center gap-2 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden"
: `cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`;
const bodyClassName =
layout === "rows"
? "border-t border-border/60"
: values
? "space-y-3 pt-1"
: "space-y-3 border-t p-4";
const descriptionClassName =
layout === "rows" ? "pl-5 text-sm text-muted-foreground" : "text-sm text-muted-foreground";
return ( return (
<div <div
className={ className={styles.wrapper}
layout === "rows"
? "grid items-start gap-x-8 gap-y-3 @min-[900px]:grid-cols-2"
: "grid items-start gap-4 @min-[900px]:grid-cols-2"
}
onInvalidCapture={(event) => { onInvalidCapture={(event) => {
// Abrir antes de que el navegador intente enfocar un control inválido oculto. // Abrir antes de que el navegador intente enfocar un control inválido oculto.
if (event.target instanceof HTMLElement) { if (event.target instanceof HTMLElement) {
@ -100,9 +72,9 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
} }
return ( return (
<details className={sectionClassName} key={section.id} open> <details className={styles.section} key={section.id} open>
<summary className={summaryClassName}> <summary className={styles.summary}>
{layout === "rows" && ( {styles.compact && (
<ChevronRightIcon <ChevronRightIcon
aria-hidden="true" aria-hidden="true"
className="size-3 shrink-0 transition-transform group-open/field-section:rotate-90 motion-reduce:transition-none" className="size-3 shrink-0 transition-transform group-open/field-section:rotate-90 motion-reduce:transition-none"
@ -110,8 +82,8 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
)} )}
{section.name} {section.name}
</summary> </summary>
<div className={bodyClassName}> <div className={styles.body}>
{section.description && <p className={descriptionClassName}>{section.description}</p>} {section.description && <p className={styles.description}>{section.description}</p>}
{content(fields)} {content(fields)}
</div> </div>
</details> </details>
@ -119,18 +91,61 @@ export function FieldSections<T extends { sectionId?: string | undefined }>({
})} })}
{ungrouped.length > 0 && ( {ungrouped.length > 0 && (
<section className="col-span-full space-y-3"> <section className="col-span-full space-y-3">
<h4 <h4 className={styles.ungrouped}>{ungroupedLabel}</h4>
className={
layout === "rows"
? "pl-5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
: "text-sm font-medium"
}
>
{ungroupedLabel}
</h4>
{content(ungrouped)} {content(ungrouped)}
</section> </section>
)} )}
</div> </div>
); );
} }
function fieldSectionStyles(layout: FieldSectionLayout, values: boolean) {
const compact = layout === "rows" || layout === "form";
const section = compact
? "@container group/field-section"
: values
? "@container"
: "@container rounded-md border";
const summary = compact
? "flex cursor-pointer list-none items-center gap-2 py-2 text-xs font-medium uppercase tracking-wide text-muted-foreground focus-visible:outline-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden"
: `cursor-pointer text-sm font-medium focus-visible:outline-2 focus-visible:outline-ring ${values ? "py-2" : "rounded-md px-4 py-3"}`;
const body =
layout === "form"
? "space-y-3 border-t border-border/60 pt-3"
: layout === "rows"
? "border-t border-border/60"
: values
? "space-y-3 pt-1"
: "space-y-3 border-t p-4";
const description = compact
? "pl-5 text-sm text-muted-foreground"
: "text-sm text-muted-foreground";
return {
compact,
section,
summary,
body,
description,
...FIELD_SECTION_LAYOUTS[layout],
ungrouped: compact
? "pl-5 text-xs font-medium uppercase tracking-wide text-muted-foreground"
: "text-sm font-medium",
};
}
const FIELD_SECTION_LAYOUTS = {
fields: {
content: "grid gap-4 sm:grid-cols-2 @min-[900px]:grid-cols-3",
wrapper: "grid items-start gap-4 @min-[900px]:grid-cols-2",
},
rows: {
content: "divide-y divide-border/50 pl-5",
wrapper: "grid items-start gap-x-8 gap-y-3 @min-[900px]:grid-cols-2",
},
form: {
content:
"grid gap-x-5 gap-y-4 pl-5 @min-[560px]:grid-cols-2 @min-[900px]:grid-cols-3 @min-[1100px]:grid-cols-4",
wrapper: "space-y-5",
},
};

View File

@ -2,12 +2,30 @@ import type { ReactNode } from "react";
import { useId } from "react"; import { useId } from "react";
/** Bloque visual reutilizable con título accesible; no altera validación ni datos. */ /** Bloque visual reutilizable con título accesible; no altera validación ni datos. */
export function FormSection({ title, children }: { title: string; children: ReactNode }) { export function FormSection({
title,
children,
variant = "card",
}: {
title: string;
children: ReactNode;
variant?: "card" | "plain";
}) {
const id = useId(); const id = useId();
return ( return (
<section aria-labelledby={id} className="space-y-4 rounded-lg border p-4"> <section
<h3 className="text-sm font-semibold" id={id}> aria-labelledby={id}
className={variant === "plain" ? "@container space-y-3" : "space-y-4 rounded-lg border p-4"}
>
<h3
className={
variant === "plain"
? "text-xs font-medium uppercase tracking-wide text-muted-foreground"
: "text-sm font-semibold"
}
id={id}
>
{title} {title}
</h3> </h3>
{children} {children}

View File

@ -0,0 +1,106 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
import { useIsMobile } from "@repo/shadcn-ui/hooks/use-mobile";
import type { ReactNode } from "react";
import { useId, useState } from "react";
import { flushSync } from "react-dom";
import type { FieldSection } from "./field-sections";
/** Navega por grupos configurables; conserva controles y revela los inválidos antes del enfoque. */
export function TabbedFieldSections<T extends { sectionId?: string | undefined }>({
sections = [],
items,
ungroupedLabel,
label,
renderItem,
}: {
sections?: readonly FieldSection[] | undefined;
items: readonly T[];
ungroupedLabel: string;
label: string;
renderItem: (item: T) => ReactNode;
}) {
const ungroupedId = useId();
const [selected, setSelected] = useState<string>();
const mobile = useIsMobile();
const groups = sections
.map((section) => ({
...section,
fields: items.filter((item) => item.sectionId === section.id),
}))
.filter((section) => section.fields.length > 0);
const knownIds = new Set(sections.map((section) => section.id));
const ungrouped = items.filter((item) => !(item.sectionId && knownIds.has(item.sectionId)));
if (ungrouped.length > 0) {
groups.push({
id: ungroupedId,
name: ungroupedLabel,
description: "",
fields: ungrouped,
});
}
const active = groups.find((group) => group.id === selected)?.id ?? groups[0]?.id;
if (!active) {
return null;
}
return (
<Tabs
className="min-w-0 gap-5"
onValueChange={(value) => {
if (typeof value === "string") {
setSelected(value);
}
}}
orientation={mobile ? "horizontal" : "vertical"}
value={active}
>
<TabsList
aria-label={label}
className={
mobile
? "max-w-full justify-start overflow-x-auto"
: "w-44 shrink-0 items-stretch justify-start self-start"
}
variant="line"
>
{groups.map((group) => (
<TabsTrigger
className={
mobile
? "flex-none px-3"
: "h-auto flex-none justify-start rounded-none px-3 py-2 text-left whitespace-normal"
}
key={group.id}
value={group.id}
>
{group.name}
</TabsTrigger>
))}
</TabsList>
<div className="@container min-w-0 flex-1">
{groups.map((group) => (
<TabsContent
className="space-y-3 data-[hidden]:hidden"
keepMounted
key={group.id}
onInvalidCapture={() => {
// El navegador debe poder enfocar controles de cualquier panel al validar.
flushSync(() => setSelected(group.id));
}}
value={group.id}
>
{group.description && (
<p className="text-sm text-muted-foreground">{group.description}</p>
)}
<div className="grid gap-x-5 gap-y-4 @min-[480px]:grid-cols-2 @min-[800px]:grid-cols-3">
{group.fields.map(renderItem)}
</div>
</TabsContent>
))}
</div>
</Tabs>
);
}

View File

@ -4,6 +4,7 @@ import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { FieldSections } from "./components/field-sections"; import { FieldSections } from "./components/field-sections";
import { TabbedFieldSections } from "./components/tabbed-field-sections";
import { TemplateFieldSummary } from "./components/template-field-summary"; import { TemplateFieldSummary } from "./components/template-field-summary";
import { copyTemplateLayout, removeTemplateSection } from "./lib/template-sections"; import { copyTemplateLayout, removeTemplateSection } from "./lib/template-sections";
@ -106,3 +107,29 @@ test("consulta compacta conserva grupos, campos sin sección y avisos sin tarjet
assert.equal(withoutSections.includes("<summary"), false); assert.equal(withoutSections.includes("<summary"), false);
assert.ok(withoutSections.includes(">notes<")); assert.ok(withoutSections.includes(">notes<"));
}); });
test("pestañas de grupos conservan controles ocultos, orden y campos sin grupo, omitiendo grupos vacíos", () => {
const html = renderToStaticMarkup(
<TabbedFieldSections
items={[...fields, { fieldId: "unknown", sectionId: "removed", required: false }]}
label="Características"
renderItem={(field) => (
<input defaultValue={field.fieldId} key={field.fieldId} required={field.required} />
)}
sections={[...sections, { id: "empty", name: "Vacío", description: "" }]}
ungroupedLabel="Sin sección"
/>
);
assert.ok(html.includes('role="tablist"'));
assert.ok(html.includes('aria-label="Características"'));
assert.equal((html.match(/role="tab"/g) ?? []).length, 3);
assert.equal((html.match(/role="tabpanel"/g) ?? []).length, 3);
assert.ok(html.indexOf(">Hardware<") < html.indexOf(">Compra<"));
assert.equal(html.includes("Vacío"), false);
for (const value of ["ram", "date", "notes", "unknown"]) {
assert.equal((html.match(new RegExp(`value="${value}"`, "g")) ?? []).length, 1);
}
assert.ok(html.includes('hidden=""'));
assert.ok(html.includes('required=""'));
});

View File

@ -0,0 +1,14 @@
/** Indexa únicamente textos de campos; no conoce entidades ni catálogos de la aplicación. */
export function fieldHelpById(
items: readonly { id: string; values: { kind: string; description: string } }[]
) {
const help = new Map<string, string>();
for (const item of items) {
if (item.values.kind === "field") {
help.set(item.id, item.values.description);
}
}
return help;
}