diff --git a/.inspect-catalog-db.ts b/.inspect-catalog-db.ts new file mode 100644 index 0000000..22d7797 --- /dev/null +++ b/.inspect-catalog-db.ts @@ -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(); \ No newline at end of file diff --git a/TODO.md b/TODO.md index 2f1ebc6..b731ca9 100644 --- a/TODO.md +++ b/TODO.md @@ -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: - -```plaintext -modules//src/web/ -``` - -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 - - - - +- 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. +- Poder eliminar un activo global personalizado +- 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 diff --git a/apps/server/src/assets.integration.ts b/apps/server/src/assets.integration.ts index eb765da..8971008 100644 --- a/apps/server/src/assets.integration.ts +++ b/apps/server/src/assets.integration.ts @@ -44,8 +44,22 @@ export async function verifyAssets( 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); + 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) => @@ -55,11 +69,9 @@ export async function verifyAssets( const stock = initial.items.find((item) => item.values.name === "Procesador"); assert.ok(stock); - await error( - `${global}/${stock.id}/update`, - { values: { ...stock.values, name: "Renombrado" } }, - "BUILTIN_PROTECTED" - ); + const fieldValues = { ...stock.values, description: "Campo global editado" }; + + await save(`${global}/${stock.id}/update`, fieldValues); assert.equal( (await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example")) .status, @@ -71,6 +83,10 @@ export async function verifyAssets( 403 ); 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. await database.query("ALTER TABLE asset_definitions DROP COLUMN icon"); await createAssetModule(database, (_request, _response, next) => next()); @@ -78,11 +94,15 @@ export async function verifyAssets( const restarted = await read(global); assert.equal(restarted.items.find((item) => item.id === stock.id)?.effectiveActive, false); - assert.ok( - restarted.items - .filter((item) => item.values.kind === "template") - .every((item) => item.values.kind === "template" && item.values.icon) - ); + assert.deepEqual(restarted.items.find((item) => item.id === stock.id)?.values, fieldValues); + const editedLaptop = restarted.items.find((item) => item.id === laptop.id); + + 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( restarted.items.map((item) => item.id).sort(), initial.items.map((item) => item.id).sort() diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index 173c157..4e9421e 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -172,11 +172,21 @@ export async function verifyResources( const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo"); assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template"); - await error( - `${global}/${stock.id}/update`, - { values: { ...stock.values, name: "Cambio" } }, - "BUILTIN_PROTECTED" - ); + const editedValues = { ...stockTemplate.values, description: "Plantilla global editada" }; + const edited = await save(`${global}/${stockTemplate.id}/update`, editedValues); + + 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( (await request(`${global}/${stock.id}/status`, { active: false }, "https://other.example")) .status, @@ -195,6 +205,8 @@ export async function verifyResources( await createResourceModule(database, (_request, _response, next) => next()); const restarted = await read(global); + assert.deepEqual(restarted.items.find((item) => item.id === stock.id)?.values, fieldValues); + assert.deepEqual( restarted.items.map((item) => item.id).sort(), initial.items.map((item) => item.id).sort() diff --git a/docs/campos-y-plantillas-de-activos.md b/docs/campos-y-plantillas-de-activos.md index bc7e26a..b932746 100644 --- a/docs/campos-y-plantillas-de-activos.md +++ b/docs/campos-y-plantillas-de-activos.md @@ -15,13 +15,13 @@ El selector de campos reutiliza SelectWithAction: el botón «+» abre el alta d ## 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. -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 @@ -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. -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. 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. diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 9ed3355..c348d76 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -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. -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. @@ -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. 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. diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md index 665e336..03a2906 100644 --- a/docs/recursos-campos-y-plantillas.md +++ b/docs/recursos-campos-y-plantillas.md @@ -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. -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 @@ -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. -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. @@ -97,3 +97,5 @@ Las plantillas permiten organizar campos en secciones ordenadas, globales o prop ## 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 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. diff --git a/modules/assets/src/api/domain/asset-catalog-seed.ts b/modules/assets/src/api/domain/asset-catalog-seed.ts index 396e9da..c329b1d 100644 --- a/modules/assets/src/api/domain/asset-catalog-seed.ts +++ b/modules/assets/src/api/domain/asset-catalog-seed.ts @@ -6,13 +6,47 @@ import type { AssetCatalog } from "./asset-catalog.aggregate"; import type { AssetDefinitionSpec, AssetFieldType } from "./asset-definition"; 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", ""], ["model", "Modelo", "text", ""], ["serial", "Número de serie", "text", ""], ["processor", "Procesador", "text", ""], ["memory", "Memoria RAM", "number", "GB"], ["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", ""], ["hostname", "Nombre de equipo", "text", ""], ["screen_size", "Pulgadas", "number", "pulgadas"], @@ -39,22 +73,37 @@ const FIELDS: readonly [string, string, AssetFieldType, string][] = [ ["warranty_until", "Fin de garantía", "date", ""], ]; 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[]][] = [ [ "laptop", "Portátil", - [ - "processor", - "memory", - "storage", - "os", - "hostname", - "screen_size", - "resolution", - "connections", - "ip", - "mac", - ], + LAPTOP_SECTION_FIELDS.flatMap(([, keys]) => keys).filter((key) => !COMMON.includes(key)), ], [ "desktop", @@ -120,19 +169,27 @@ export function seedAssetCatalog(catalog: AssetCatalog) { current = result.data; }; - for (const [key, name, type, unit] of FIELDS) { - add(`field:${key}`, { kind: "field", name, description: "", type, unit, choices: [] }); + for (const [key, name, type, unit, choices = []] of FIELDS) { + add(`field:${key}`, { + kind: "field", + name, + description: "", + type, + unit, + choices: [...choices], + }); } for (const [key, name, fields] of TEMPLATES) { const sectionFields = [...COMMON, ...fields]; - const sections = SECTION_FIELDS.filter(([, keys]) => - keys.some((field) => sectionFields.includes(field)) - ).map(([name, keys]) => ({ - id: UniqueID.generateNewID().toPrimitive(), - name, - description: "", - keys, - })); + const sectionLayout = key === "laptop" ? LAPTOP_SECTION_FIELDS : SECTION_FIELDS; + const sections = sectionLayout + .filter(([, keys]) => keys.some((field) => sectionFields.includes(field))) + .map(([name, keys]) => ({ + id: UniqueID.generateNewID().toPrimitive(), + name, + description: "", + keys, + })); const references = [...COMMON, ...fields].map((field) => { const id = current.definitions .find((item) => item.stockKey.getOrUndefined() === `field:${field}`) diff --git a/modules/assets/src/api/domain/asset-catalog.aggregate.ts b/modules/assets/src/api/domain/asset-catalog.aggregate.ts index ed31334..92ad6a8 100644 --- a/modules/assets/src/api/domain/asset-catalog.aggregate.ts +++ b/modules/assets/src/api/domain/asset-catalog.aggregate.ts @@ -137,11 +137,18 @@ export class AssetCatalog extends AggregateRoot { }); } - seed(stockKey: string, values: AssetDefinitionValues): Result { + seed( + stockKey: string, + values: AssetDefinitionValues, + options: { refreshTemplate?: boolean } = {} + ): Result { const previous = this.definitions.find((item) => item.stockKey.getOrUndefined() === stockKey); if (previous) { - const upgraded = previous.initializeSections(values).initializeIcon(values); + if (!options.refreshTemplate) { + return Result.ok(this); + } + const upgraded = previous.refreshBuiltinTemplate(values); if (upgraded === previous) { return Result.ok(this); diff --git a/modules/assets/src/api/domain/asset-catalog.test.ts b/modules/assets/src/api/domain/asset-catalog.test.ts index 310e208..c15c7a2 100644 --- a/modules/assets/src/api/domain/asset-catalog.test.ts +++ b/modules/assets/src/api/domain/asset-catalog.test.ts @@ -8,7 +8,7 @@ import { AssetCatalog } from "./asset-catalog.aggregate"; import { seedAssetCatalog } from "./asset-catalog-seed"; import type { AssetDefinitionSpec } 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"; const global = Maybe.none(); @@ -34,7 +34,7 @@ function changed(result: ReturnType) { test("el catálogo inicial es idempotente, con UUID propios y características opcionales", () => { const catalog = seedAssetCatalog(AssetCatalog.create()); - assert.equal(catalog.definitions.length, 47); + assert.equal(catalog.definitions.length, 75); assert.equal(seedAssetCatalog(catalog), catalog); const ids = [ catalog.id, @@ -60,8 +60,11 @@ test("el catálogo inicial es idempotente, con UUID propios y características o assert.ok(stock); const protectedResult = catalog.save(global, Maybe.some(stock.id), field("Cambio")); - assert.ok(!protectedResult.isSuccess); - assert.equal(protectedResult.error.code, "BUILTIN_PROTECTED"); + assert.ok(protectedResult.isSuccess); + 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", () => { 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, second), true); assert.equal(stock.active, true); - assert.equal(catalog.definitions.length, 47); + assert.equal(catalog.definitions.length, 75); const overrideId = catalog.availability[0]?.id; 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 template = initial.definitions.find( (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)); 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.deepEqual(saved.values.data.fields, template.values.data.fields); assert.deepEqual(saved.bindings, template.bindings); @@ -270,3 +273,265 @@ test("los iconos desconocidos o asociados a campos se rechazan", () => { 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"); +}); diff --git a/modules/assets/src/api/domain/asset-definition.entity.ts b/modules/assets/src/api/domain/asset-definition.entity.ts index 2f10fa4..6cb4cc2 100644 --- a/modules/assets/src/api/domain/asset-definition.entity.ts +++ b/modules/assets/src/api/domain/asset-definition.entity.ts @@ -84,7 +84,7 @@ export class AssetDefinition extends DomainEntity { } update(values: AssetDefinitionValues): Result { - if (this.stockKey.getOrUndefined()) { + if (this.stockKey.getOrUndefined() && this.values.data.kind === "relation") { return Result.fail(new AssetCatalogError("BUILTIN_PROTECTED")); } const previous = this.values.data; @@ -177,6 +177,81 @@ export class AssetDefinition extends DomainEntity { 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) { return active === this.active ? this : new AssetDefinition({ ...this.props, active }, this.id); } diff --git a/modules/assets/src/api/domain/template-sections.test.ts b/modules/assets/src/api/domain/template-sections.test.ts index 8c20958..40a840c 100644 --- a/modules/assets/src/api/domain/template-sections.test.ts +++ b/modules/assets/src/api/domain/template-sections.test.ts @@ -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 }); -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 grouped = seeded.definitions.find( (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()), 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.ok(next.sections.every((item) => item.id.toPrimitive()[14] === "7")); }); diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index 952d913..a617d75 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -242,7 +242,9 @@ "optional": "Optional", "remove": "Remove field: {{name}}", "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?", "examplesLabel": "Show examples and guidance", "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.", "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.", - "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.", "COMPANY_NOT_FOUND": "Company not found.", "INTERNAL_ERROR": "The operation could not be completed.", diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index ab8e843..d0e23f2 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -242,7 +242,9 @@ "optional": "Opcional", "remove": "Retirar campo: {{name}}", "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?", "examplesLabel": "Ver ejemplos y recomendaciones", "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.", "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.", - "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.", "COMPANY_NOT_FOUND": "No se ha encontrado la compañía.", "INTERNAL_ERROR": "No se ha podido completar la operación.", diff --git a/modules/assets/src/web/assets/shared/ui/asset-characteristic-control.tsx b/modules/assets/src/web/assets/shared/ui/asset-characteristic-control.tsx index f7be79b..13b67b9 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-characteristic-control.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-characteristic-control.tsx @@ -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 { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; @@ -13,15 +14,18 @@ export function AssetCharacteristicControl({ onChange, prefix, disabled, + helpText, }: { field: AssetCharacteristic; value: CharacteristicValue | undefined; onChange: (value: CharacteristicValue | undefined) => void; prefix: string; disabled: boolean; + helpText?: string | undefined; }) { const { t } = useTranslation("assets"); const id = `${prefix}-${field.fieldId}`; + const describedBy = fieldHelpId(id, helpText); const label = `${field.name}${field.unit ? ` (${field.unit})` : ""}${field.required ? " *" : ""}`; return ( @@ -29,6 +33,7 @@ export function AssetCharacteristicControl({ {field.type === "choice" || field.type === "boolean" ? ( ) : ( )} + ); } diff --git a/modules/assets/src/web/assets/shared/ui/asset-form.tsx b/modules/assets/src/web/assets/shared/ui/asset-form.tsx index eb224a3..0626547 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-form.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-form.tsx @@ -1,12 +1,14 @@ 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 { FormSection } from "@repo/rdx-ui/components/form-section"; 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 { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; +import type { FormEvent } from "react"; import { useId } from "react"; import { useTranslation } from "react-i18next"; @@ -42,17 +44,49 @@ export function AssetForm(props: AssetFormProps) { const prefix = useId(); const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") }; + const submitWithTabs = async (event: FormEvent) => { + 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 ( <> -
+
- -
- - -
-
+ +
+
+ + +
({ value, label: t(value) }))} + options={AssetStatusSchema.options.map((value) => ({ + value, + label: t(value), + }))} placeholder={t("chooseValue")} required value={values.status} @@ -108,24 +145,28 @@ export function AssetForm(props: AssetFormProps) { id={`${prefix}-site`} label={t("site")} 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 ?? ""} />
{fields.length > 0 && ( -
-

{t("characteristics")}

- 0} + + field.fieldId === brandFieldId ? ( setCharacteristic(field.fieldId, value)} prefix={prefix} @@ -154,7 +196,7 @@ export function AssetForm(props: AssetFormProps) { sections={sections} ungroupedLabel={t("sections.ungrouped")} /> -
+ )}
@@ -179,6 +221,7 @@ export function AssetForm(props: AssetFormProps) { guidance={t("assetGuidance")} scopeNote={t("assetScope")} title={t("helpTitle")} + variant="plain" /> diff --git a/modules/assets/src/web/brands/shared/types/brand-select-props.ts b/modules/assets/src/web/brands/shared/types/brand-select-props.ts index 9ecfb08..9cfe268 100644 --- a/modules/assets/src/web/brands/shared/types/brand-select-props.ts +++ b/modules/assets/src/web/brands/shared/types/brand-select-props.ts @@ -3,6 +3,7 @@ import type { BrandDTO } from "../../../../common/brands"; export type BrandSelectProps = { id: string; label: string; + helpText?: string | undefined; brandId: string | null; legacyName: string; disabled: boolean; diff --git a/modules/assets/src/web/brands/shared/ui/brand-select.tsx b/modules/assets/src/web/brands/shared/ui/brand-select.tsx index 3993f08..56c1f0d 100644 --- a/modules/assets/src/web/brands/shared/ui/brand-select.tsx +++ b/modules/assets/src/web/brands/shared/ui/brand-select.tsx @@ -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 { Button } from "@repo/shadcn-ui/components/button"; 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 { query, open, setOpen, brands, legacy, options } = useBrandSelectController(props); const { t } = useTranslation("assets"); + const describedBy = fieldHelpId(id, props.helpText); if (legacy) { options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.legacy")})` }); @@ -29,6 +31,7 @@ export function BrandSelect(props: BrandSelectProps) { action={ allowQuickCreate ? { label: t("brands.new"), onClick: () => setOpen(true) } : undefined } + describedBy={describedBy} disabled={disabled || query.isPending || query.isError} id={id} label={label} @@ -47,6 +50,7 @@ export function BrandSelect(props: BrandSelectProps) { required={required} value={brandId ?? (legacy ? LEGACY : "")} /> + {query.isError && (

{t("brands.loadError")}

diff --git a/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx b/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx index b174004..c6c7731 100644 --- a/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx +++ b/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx @@ -57,13 +57,17 @@ export function CatalogFieldForm(props: CatalogFieldFormProps) {
)}
- +