feat: add company logo management functionality

- Implemented CompanyLogoUseCases for retrieving, saving, and removing company logos.
- Created CompanyLogoError class for error handling related to logo operations.
- Developed CompanyLogoProcessor to optimize and validate logo images.
- Added HTTP routes for logo management, including GET and POST endpoints.
- Created Sequelize-based repository for logo persistence.
- Implemented migration for adding logoVersion to companies and creating company_logos table.
- Added frontend components for displaying and managing company logos, including avatar and logo upload fields.
- Wrote tests for logo processing and avatar components to ensure functionality and correctness.
This commit is contained in:
David Arranz 2026-10-08 18:31:46 +02:00
parent 1f21a605cc
commit 5515191eff
63 changed files with 2546 additions and 1048 deletions

433
TODO.md
View File

@ -2,4 +2,435 @@
- Traducciones: Quiero que audites todo el código para extraer y reunir todas las cadenas de traducciones en ficheros JSON. El modelo a seguir es el módulo "core". En "modules/core/src/common/locales" se pueden ver los fichero "es.json" y "en.json" con las cadenas de cada idioma. Quiero lo mismo para el resto de módulos. Asi es más facil de mantener y de añadir nuevos idiomas ya que todo está localizado en un mismo sitio.
- Quiero añadir una página "Home" a modo de tablero inicial o dashboard inicial. Quiero que en esa página inicial se muestre información resumen para que el usuario, cuando entre, se haga una idea de la situación de un vistazo. Revisa si hay una pagina similar en otras aplicaciones similares del mercado y muestra en una lista sugerencias de la información a presentar. No cambies nada.
- Quiero añadir una página "Home" a modo de tablero inicial o dashboard inicial. Quiero que en esa página inicial se muestre información resumen para que el usuario, cuando entre, se haga una idea de la situación de un vistazo. Revisa si hay una pagina similar en otras aplicaciones similares del mercado y muestra en una lista sugerencias de la información a presentar. No cambies nada.
- 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/<nombre_modulo>/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.

View File

@ -0,0 +1,131 @@
import assert from "node:assert/strict";
import {
CompaniesResponseSchema,
CompanyLogoResponseSchema,
CompanyResponseSchema,
} from "@metis/companies/common";
export async function verifyCompanyLogos(
baseUrl: string,
companyId: string,
cookie: string,
csrfToken: string,
origin: string
) {
const path = `/companies/${companyId}/logo`;
const headers = {
cookie,
origin,
"x-csrf-token": csrfToken,
"content-type": "application/octet-stream",
};
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAACACAIAAABr1yBdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACrElEQVR4nO3cwQ3EIBAEQVLpBAmIaB2B3/vYkgjg1G5kw87c6T4LgbZqcMZ/gYVANgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUshWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgIBGGAkQeCqRJEDg6QSTAAGleBLc7dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBACjEkQCCNMBIgkEokCRBIJ5gEqSMrxZOg3dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBA/9vgAyvyiK2z3NTsAAAAAElFTkSuQmCC",
"base64"
);
assert.equal((await fetch(`${baseUrl}${path}?version=${"a".repeat(64)}`)).status, 401);
assert.equal(
(
await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: { ...headers, "x-csrf-token": "invalid" },
body: png,
})
).status,
403
);
assert.equal(
(
await fetch(`${baseUrl}${path}`, {
method: "POST",
headers,
body: Buffer.from("invalid image"),
})
).status,
400
);
assert.equal(
(
await fetch(`${baseUrl}${path}`, {
method: "POST",
headers,
body: Buffer.alloc(2 * 1024 * 1024 + 1),
})
).status,
413
);
const upload = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png });
assert.equal(upload.status, 200);
const logo = CompanyLogoResponseSchema.parse(await upload.json());
assert.ok(logo.logoVersion);
const url = `${baseUrl}${path}?version=${logo.logoVersion}`;
const image = await fetch(url, { headers: { cookie } });
assert.equal(image.status, 200);
assert.equal(image.headers.get("content-type"), "image/webp");
assert.match(image.headers.get("cache-control") ?? "", /private/);
const bytes = Buffer.from(await image.arrayBuffer());
assert.ok(bytes.length <= 64 * 1024);
assert.equal(bytes.subarray(8, 12).toString(), "WEBP");
assert.equal(
(await fetch(url, { headers: { cookie, "if-none-match": image.headers.get("etag") ?? "" } }))
.status,
304
);
assert.equal(
(await fetch(`${baseUrl}${path}?version=${"b".repeat(64)}`, { headers: { cookie } })).status,
404
);
const repeated = await fetch(`${baseUrl}${path}`, { method: "POST", headers, body: png });
assert.deepEqual(CompanyLogoResponseSchema.parse(await repeated.json()), logo);
const detail = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
assert.equal(
CompanyResponseSchema.parse(await detail.json()).company.logoVersion,
logo.logoVersion
);
const list = await fetch(`${baseUrl}/companies`, { headers: { cookie } });
assert.equal(
CompaniesResponseSchema.parse(await list.json()).companies.find((item) => item.id === companyId)
?.logoVersion,
logo.logoVersion
);
const replacement = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers,
body: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAQAAAACACAIAAABr1yBdAAAACXBIWXMAAAPoAAAD6AG1e1JrAAACrElEQVR4nO3cwQ3EIBAEQVIgSYLrKB2B3/vYkgjg1G5kw87c6V0LgbZqcMZ/gYVANgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUshWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgEAaYSRAIJVIEiCQTjAJUkdWiidBu7eBf4WYfwZWNgAJEMgbgAQI5BOIBAjkDEACBHIIJgECuQUiAQK5BiUBApkDkACBDMJIgEAmwSRAIFEIEiCQLBAJEEgYjgQIJA1KAgQShyYBAukDkACBFGJIgIBGGAkQuCqRJEDg6gSTAAGleBK87dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBACjEkQCCNMBIgkEokCRBIJ5gEqSMrxZOg3dvAv0LMPwMrG4AECOQNQAIE8glEAgRyBiABAjkEkwCB3AKRAIFcg5IAgcwBSIBABmEkQCCTYBIgkCgECRBIFogECCQMRwIEkgYlAQKJQ5MAgfQBSIBA/9vgAyJWiHFIq0dWAAAAAElFTkSuQmCC",
"base64"
),
});
assert.equal(replacement.status, 200);
const replaced = CompanyLogoResponseSchema.parse(await replacement.json());
assert.notEqual(replaced.logoVersion, logo.logoVersion);
assert.equal((await fetch(url, { headers: { cookie } })).status, 404);
const updatedUrl = `${baseUrl}${path}?version=${replaced.logoVersion}`;
assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 200);
const current = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
const company = CompanyResponseSchema.parse(await current.json()).company;
const update = await fetch(`${baseUrl}/companies/${companyId}/update`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ name: company.name, active: company.active }),
});
assert.equal(update.status, 200);
assert.equal(
CompanyResponseSchema.parse(await update.json()).company.logoVersion,
replaced.logoVersion
);
for (let repeat = 0; repeat < 2; repeat++) {
const removed = await fetch(`${baseUrl}${path}/remove`, {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: "{}",
});
assert.equal(removed.status, 200);
assert.equal(CompanyLogoResponseSchema.parse(await removed.json()).logoVersion, null);
}
assert.equal((await fetch(url, { headers: { cookie } })).status, 404);
assert.equal((await fetch(updatedUrl, { headers: { cookie } })).status, 404);
const cleared = await fetch(`${baseUrl}/companies/${companyId}`, { headers: { cookie } });
assert.equal(CompanyResponseSchema.parse(await cleared.json()).company.logoVersion, null);
}

View File

@ -27,6 +27,7 @@ import { verifyApplications } from "./applications.integration";
import { verifyAssets } from "./assets.integration";
import { verifyBrands } from "./brands.integration";
import { verifyCertificates } from "./certificates.integration";
import { verifyCompanyLogos } from "./company-logos.integration";
import { createDatabase, readDatabaseEnvironment } from "./config/database";
import {
verifyDashboard,
@ -864,6 +865,13 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de
secondCompany.id,
database
);
await verifyCompanyLogos(
baseUrl,
customer.id,
operator.cookie(),
(await operator.session()).csrfToken,
environment.APP_ORIGIN
);
await verifyDashboardBoundaries((...args) => operator.request(...args));
await verifyDashboardInactiveAccess((...args) => operator.request(...args), secondCompany.id);
await verifyDashboard((...args) => operator.request(...args), customer.id, secondCompany.id);

View File

@ -6,7 +6,7 @@
<meta name="description" content="Metis Desk — soporte técnico con contexto.">
<title>Metis Desk</title>
</head>
<body>
<body class="font-sans has-[[data-slot=workspace]]:bg-shell-exterior">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@ -13,6 +13,9 @@ import {
dashboardKeys,
entryDestination,
} from "../../../modules/companies/src/web/components/dashboard-api";
import { DashboardNavigationButton } from "../../../modules/companies/src/web/components/dashboard-navigation-button";
import { DashboardStateBadge } from "../../../modules/companies/src/web/components/dashboard-state-badge";
import { DashboardTable } from "../../../modules/companies/src/web/components/dashboard-table";
import { App } from "./app";
import { createAppI18n } from "./i18n";
@ -25,6 +28,55 @@ const user: UserDTO = {
active: true,
isInitialAdmin: false,
};
test("las acciones del dashboard conservan nombre, selección y deshabilitado sin enviar formularios", () => {
const markup = renderToStaticMarkup(
<DashboardNavigationButton aria-label="Open tickets: 2" aria-pressed disabled>
2
</DashboardNavigationButton>
);
assert.ok(markup.includes('aria-label="Open tickets: 2"'));
assert.ok(markup.includes('aria-pressed="true"'));
assert.ok(markup.includes('disabled=""'));
assert.ok(markup.includes('type="button"'));
assert.ok(markup.includes(">2</button>"));
});
test("los estados del dashboard mantienen texto observable además del color", () => {
for (const state of ["urgent", "high", "in_progress", "open", null]) {
const label = state ?? "Unknown";
const markup = renderToStaticMarkup(
<DashboardStateBadge state={state}>{label}</DashboardStateBadge>
);
assert.ok(markup.includes(`>${label}</span>`));
if (state === null) {
assert.equal(markup.includes("data-state="), false);
} else {
assert.ok(markup.includes(`data-state="${state}"`));
}
}
});
test("las tablas del dashboard conservan caption, cabeceras semánticas y contenido", () => {
const markup = renderToStaticMarkup(
<DashboardTable caption="Pending work">
<thead>
<tr>
<th scope="col">Reference</th>
</tr>
</thead>
<tbody>
<tr>
<td>T-2026-001</td>
</tr>
</tbody>
</DashboardTable>
);
assert.match(markup, /<caption[^>]*>Pending work<\/caption>/);
assert.ok(markup.includes('<th scope="col">Reference</th>'));
assert.ok(markup.includes("<td>T-2026-001</td>"));
});
async function renderPage(
language: string,
session?: SessionResponseDTO,

View File

@ -11,11 +11,11 @@ export function App() {
retry: false,
});
return (
<div className="desk-app">
<div className="min-h-dvh bg-shell-content">
{!session.data?.user && (
<header className="desk-login-header">
<header className="flex items-center justify-between px-[30px] py-5">
<strong>Metis Desk</strong>
<LanguageSwitcher />
<LanguageSwitcher className="mt-0" />
</header>
)}
<IdentityWorkspace

View File

@ -3,722 +3,37 @@
@source "../../../modules";
@source "../../../packages/shadcn-ui/src/components";
body {
margin: 0;
font-family: ui-sans-serif, system-ui, sans-serif;
}
/* Tokens del shell aislados de los colores semánticos de shadcn/ui. */
:root {
--desk-app-background: #000;
--desk-content-background: #f7f9fb;
--desk-header-background: #fff;
--desk-sidebar-background: #0e282c;
--desk-sidebar-top: #0c252a;
--desk-sidebar-bottom: #102c30;
--desk-sidebar-text: #f4f7f7;
--desk-sidebar-muted: rgb(255 255 255 / 82%);
--desk-accent: #00d2c1;
--desk-active-start: rgb(0 188 177 / 40%);
--desk-active-end: rgb(0 128 130 / 45%);
--desk-navy: #0c2850;
--desk-muted-navy: #607895;
--desk-border: #d1d9e2;
--desk-radius-sm: 8px;
--desk-radius-md: 10px;
--desk-radius-shell: 20px;
--desk-sidebar-width: 228px;
--desk-sidebar-inset: 20px;
--desk-sidebar-collapsed-width: 72px;
--desk-header-height: 88px;
--desk-search-height: 41px;
--desk-nav-height: 48px;
--desk-avatar-size: 35px;
}
.desk-app {
min-height: 100dvh;
background: var(--desk-content-background);
}
.desk-login-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 30px;
}
.desk-language {
margin-top: 16px;
}
.desk-login-header .desk-language {
margin-top: 0;
}
.desk-workspace {
display: flex;
height: 100dvh;
overflow: hidden;
border-radius: var(--desk-radius-shell);
background: var(--desk-app-background);
}
body:has(.desk-workspace) {
background: var(--desk-app-background);
}
.desk-sidebar {
display: flex;
flex-direction: column;
min-height: 0;
width: var(--desk-sidebar-width);
flex: 0 0 var(--desk-sidebar-width);
overflow: hidden;
background: linear-gradient(
180deg,
var(--desk-sidebar-top),
var(--desk-sidebar-background) 45%,
var(--desk-sidebar-bottom)
);
color: var(--desk-sidebar-text);
}
.desk-brand {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 13px;
padding: 31px 0 0 30px;
white-space: nowrap;
font-size: 21px;
font-weight: 650;
line-height: 30px;
letter-spacing: -0.5px;
}
.desk-brand svg {
width: 32px;
height: 30px;
color: var(--desk-accent);
flex-shrink: 0;
}
.desk-company-rail {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
padding: 28px var(--desk-sidebar-inset) 24px;
}
.desk-space-button {
width: 100%;
min-height: var(--desk-nav-height);
height: var(--desk-nav-height);
flex-shrink: 0;
justify-content: flex-start;
gap: 19px;
padding: 0 12px 0 14px;
border: 0;
border-left: 2px solid transparent;
border-radius: var(--desk-radius-sm);
color: var(--desk-sidebar-text);
font-size: 16px;
font-weight: 400;
background: transparent;
box-shadow: none;
}
.desk-space-button > svg {
width: 24px;
height: 24px;
stroke-width: 1.6;
flex-shrink: 0;
}
.desk-space-button > span {
overflow: hidden;
text-overflow: ellipsis;
}
.desk-space-button > .desk-nav-chevron {
margin-left: auto;
width: 18px;
height: 18px;
}
.desk-space-button:hover {
background: rgb(255 255 255 / 6%);
color: white;
}
.desk-space-button[aria-pressed="true"] {
color: white;
border-left-color: var(--desk-accent);
background: linear-gradient(90deg, var(--desk-active-start), var(--desk-active-end));
}
.desk-rail-separator {
height: 1px;
background: rgb(255 255 255 / 12%);
margin: 26px 0 25px;
flex-shrink: 0;
}
.desk-catalog-navigation {
display: grid;
padding-left: 18px;
}
.desk-catalog-navigation > button {
height: auto;
min-height: 38px;
justify-content: flex-start;
white-space: normal;
text-align: left;
color: var(--desk-sidebar-muted);
}
.desk-catalog-navigation > button[aria-pressed="true"] {
background: rgb(255 255 255 / 8%);
color: white;
}
.desk-sidebar-footer {
flex-shrink: 0;
display: grid;
gap: 2px;
padding: 12px var(--desk-sidebar-inset) 20px;
border-top: 1px solid rgb(255 255 255 / 12%);
}
.desk-sidebar-toggle[aria-expanded="true"] > svg {
transform: rotate(180deg);
}
.desk-catalog-navigation > button {
gap: 10px;
}
.desk-catalog-navigation > button > svg {
width: 18px;
height: 18px;
flex-shrink: 0;
}
@media (min-width: 768px) {
.desk-sidebar[data-collapsed="true"] {
width: var(--desk-sidebar-collapsed-width);
flex-basis: var(--desk-sidebar-collapsed-width);
}
.desk-sidebar[data-collapsed="true"] .desk-brand {
justify-content: center;
padding-left: 0;
}
.desk-sidebar[data-collapsed="true"] .desk-brand > span,
.desk-sidebar[data-collapsed="true"] .desk-space-button > span,
.desk-sidebar[data-collapsed="true"] .desk-nav-chevron,
.desk-sidebar[data-collapsed="true"] .desk-catalog-navigation > button > span {
display: none;
}
.desk-sidebar[data-collapsed="true"] .desk-company-rail {
padding: 28px 12px 12px;
}
.desk-sidebar[data-collapsed="true"] .desk-sidebar-footer {
padding: 12px 12px 20px;
}
.desk-sidebar[data-collapsed="true"] .desk-space-button {
justify-content: center;
gap: 0;
padding: 0;
}
.desk-sidebar[data-collapsed="true"] .desk-catalog-navigation {
padding-left: 0;
}
.desk-sidebar[data-collapsed="true"] .desk-catalog-navigation > button {
justify-content: center;
padding-inline: 0;
}
}
.desk-workspace-content {
flex: 1;
min-width: 0;
overflow-y: auto;
background: var(--desk-content-background);
}
.desk-global-header {
display: flex;
align-items: center;
gap: 28px;
min-height: var(--desk-header-height);
padding: 15px 30px 15px 17px;
background: var(--desk-header-background);
border-bottom: 1px solid var(--desk-border);
color: var(--desk-navy);
}
.desk-search {
position: relative;
flex: 1;
min-width: 0;
}
.desk-search > svg {
position: absolute;
left: 14px;
top: 11px;
width: 19px;
height: 19px;
color: #1d3e6e;
pointer-events: none;
}
.desk-search > input {
height: var(--desk-search-height);
padding-left: 43px;
border-radius: var(--desk-radius-sm);
border-color: var(--desk-border);
background: rgb(255 255 255 / 65%);
box-shadow: none;
font-size: 14px;
color: var(--desk-navy);
}
.desk-search > input::placeholder {
color: var(--desk-muted-navy);
}
.desk-search-results,
.desk-profile-menu {
position: absolute;
top: calc(100% + 8px);
z-index: 30;
padding: 12px;
border: 1px solid var(--desk-border);
border-radius: var(--desk-radius-md);
background: var(--desk-content-background);
box-shadow: 0 3px 12px rgb(12 40 80 / 8%);
}
.desk-search-results {
left: 0;
right: 0;
max-height: 320px;
overflow-y: auto;
display: grid;
}
.desk-search-results > button {
justify-content: flex-start;
}
.desk-search-results p {
font-size: 12px;
color: var(--desk-muted-navy);
padding: 8px;
}
.desk-notifications {
display: flex;
flex: 0 0 24px;
}
.desk-notifications svg {
width: 24px;
height: 24px;
stroke-width: 1.6;
}
.desk-profile {
position: relative;
flex-shrink: 0;
}
.desk-profile summary {
display: flex;
align-items: center;
gap: 13px;
cursor: pointer;
list-style: none;
}
.desk-profile summary::-webkit-details-marker {
display: none;
}
.desk-profile summary > svg {
width: 16px;
height: 16px;
margin-left: 2px;
}
.desk-avatar {
display: flex;
align-items: center;
justify-content: center;
width: var(--desk-avatar-size);
height: var(--desk-avatar-size);
border-radius: 50%;
background: #d9e8e9;
color: #0e282c;
font-size: 12px;
font-weight: 600;
}
.desk-profile-copy {
display: flex;
flex-direction: column;
line-height: 1.25;
max-width: 150px;
}
.desk-profile-copy strong {
font-size: 14px;
font-weight: 650;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.desk-profile-copy > span {
font-size: 12px;
color: var(--desk-muted-navy);
}
.desk-profile-menu {
right: 0;
width: 300px;
}
.desk-profile summary:focus-visible,
.desk-space-button:focus-visible {
outline: 2px solid var(--desk-accent);
outline-offset: 3px;
}
.desk-main-content {
padding: 24px;
min-width: 0;
}
.company-space {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
grid-template-areas: "heading main" "navigation main" "account main";
min-height: calc(100dvh - var(--desk-header-height));
background: var(--sidebar);
}
.company-space-heading {
grid-area: heading;
padding: 20px 16px;
border-bottom: 1px solid var(--border);
}
.company-space-heading h1 {
font-size: 1rem;
overflow-wrap: anywhere;
}
.company-space-heading > button {
margin-top: 16px;
width: 100%;
}
.company-space-navigation {
grid-area: navigation;
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 10px;
}
.company-space-navigation > button {
justify-content: flex-start;
white-space: normal;
height: auto;
min-height: 40px;
text-align: left;
}
.company-space-navigation > button[aria-pressed="true"] {
background: var(--accent);
color: var(--accent-foreground);
}
.company-space-account {
grid-area: account;
padding: 16px 10px;
border-top: 1px solid var(--border);
}
.company-space-main {
grid-area: main;
padding: 24px;
min-width: 0;
background: var(--background);
border-left: 1px solid var(--border);
}
.company-space-breadcrumb {
padding-bottom: 16px;
margin-bottom: 24px;
border-bottom: 1px solid var(--border);
font-size: 0.875rem;
color: var(--muted-foreground);
}
.dashboard-page {
max-width: 1440px;
}
.dashboard-kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px;
}
.dashboard-kpi,
.dashboard-panel {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card);
box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 5%, transparent);
}
.dashboard-kpi {
min-height: 144px;
padding: 20px;
text-align: left;
display: grid;
gap: 6px;
color: var(--muted-foreground);
}
.dashboard-kpi:not(:disabled):hover {
border-color: var(--primary);
}
.dashboard-kpi strong {
color: var(--foreground);
font-size: 2rem;
line-height: 1;
}
.dashboard-kpi-icon {
color: var(--primary);
}
.dashboard-kpi-icon svg {
width: 20px;
height: 20px;
}
.dashboard-panel {
padding: 20px;
}
.dashboard-panel h2 {
font-size: 1rem;
font-weight: 600;
}
.dashboard-panel table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.dashboard-panel th,
.dashboard-panel td {
padding: 10px 8px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.dashboard-panel th {
color: var(--muted-foreground);
font-weight: 500;
}
.dashboard-link {
color: var(--primary);
font-weight: 600;
text-align: left;
}
.dashboard-link:hover {
text-decoration: underline;
}
.dashboard-link:focus-visible,
.dashboard-alert:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 3px;
}
.dashboard-alert {
display: flex;
align-items: center;
gap: 16px;
width: 100%;
padding: 16px 0;
text-align: left;
font-size: 0.875rem;
}
.dashboard-alert strong {
font-size: 1.25rem;
}
.dashboard-alert > span {
flex: 1;
}
.dashboard-badge {
display: inline-block;
border-radius: 999px;
background: var(--muted);
padding: 3px 10px;
white-space: nowrap;
font-size: 0.75rem;
font-weight: 600;
}
.dashboard-badge[data-state="urgent"],
.dashboard-badge[data-state="high"] {
background: color-mix(in srgb, var(--destructive) 12%, var(--card));
color: var(--destructive);
}
.dashboard-badge[data-state="in_progress"] {
background: color-mix(in srgb, var(--primary) 12%, var(--card));
color: var(--primary);
}
.dashboard-timeline {
display: grid;
gap: 20px;
}
.dashboard-timeline li {
display: grid;
grid-template-columns: 110px 24px minmax(0, 1fr);
gap: 12px;
align-items: start;
font-size: 0.875rem;
}
@media (max-width: 767px) {
.dashboard-timeline li {
grid-template-columns: 24px minmax(0, 1fr);
}
.dashboard-timeline time {
grid-column: 1 / -1;
}
}
@media (max-width: 1199px) {
.dashboard-kpis {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.inventory-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 24px;
align-items: start;
}
.inventory-list {
min-width: 0;
}
.inventory-layout[data-detail-open="false"] {
grid-template-columns: minmax(0, 1fr);
}
.asset-side-panel {
display: grid;
grid-template-columns: minmax(0, 1fr);
overflow-wrap: anywhere;
gap: 24px;
padding: 20px;
min-width: 0;
background: var(--muted);
border: 1px solid var(--border);
border-radius: 12px;
}
.inventory-asset-row {
padding-inline: 12px;
border-radius: 8px;
}
.inventory-asset-selected,
.inventory-asset-selected [data-pinned="right"] {
background: var(--accent);
}
.inventory-asset-name {
text-align: left;
overflow-wrap: anywhere;
cursor: pointer;
}
.inventory-asset-name:hover {
text-decoration: underline;
}
.inventory-asset-name:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 4px;
border-radius: 4px;
}
.asset-mobile-action {
display: none;
}
@media (max-width: 1099px) {
.inventory-layout {
grid-template-columns: minmax(0, 1fr);
}
.asset-side-panel,
.asset-desktop-action {
display: none;
}
.asset-mobile-action {
display: inline-flex;
}
}
@media (max-width: 767px) {
.dashboard-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.dashboard-kpi,
.dashboard-panel {
padding: 16px;
}
.desk-workspace {
height: auto;
min-height: 100dvh;
flex-direction: column;
border-radius: 0;
}
.desk-sidebar {
width: 100%;
flex-basis: auto;
}
.desk-brand {
padding: 2px;
}
.desk-company-rail {
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
padding: 0 16px 16px;
}
.desk-space-button {
width: auto;
min-height: 42px;
height: 42px;
padding: 0 10px;
gap: 8px;
font-size: 14px;
}
.desk-space-button > svg {
width: 20px;
height: 20px;
}
.desk-rail-separator {
flex-basis: 100%;
margin: 6px 0;
}
.desk-catalog-navigation {
flex-basis: 100%;
padding: 0;
}
.desk-global-header {
gap: 14px;
min-height: 72px;
padding: 16px;
}
.desk-profile-copy {
display: none;
}
.desk-profile summary {
gap: 6px;
}
.desk-profile-menu {
width: min(300px, calc(100vw - 32px));
}
.desk-main-content {
padding: 16px;
}
.company-space {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto 1fr auto;
grid-template-areas: "heading" "navigation" "main" "account";
min-height: calc(100dvh - 140px);
}
.company-space-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
}
.company-space-heading > button {
margin-top: 0;
width: auto;
}
.company-space-navigation {
flex-direction: row;
flex-wrap: wrap;
padding: 10px;
gap: 6px;
}
.company-space-navigation > button {
font-size: 0.8125rem;
min-height: 40px;
}
.company-space-main {
padding: 16px;
border-left: 0;
border-top: 1px solid var(--border);
}
.company-space-breadcrumb {
margin-bottom: 16px;
}
.company-space-account {
padding: 12px;
}
}
@media (max-width: 767px) {
.desk-company-rail {
flex: none;
overflow-y: visible;
}
.desk-sidebar-footer {
padding: 10px 16px;
}
.desk-sidebar-toggle {
display: none;
}
/* El shell conserva su identidad visual sin alterar los tokens semánticos del vendor. */
@theme {
--breakpoint-shell: 768px;
--breakpoint-inventory: 1100px;
--breakpoint-dashboard: 1200px;
--font-sans: ui-sans-serif, system-ui, sans-serif;
--color-shell-exterior: #000;
--color-shell-content: #f7f9fb;
--color-shell-header: #fff;
--color-shell-sidebar: #0e282c;
--color-shell-sidebar-top: #0c252a;
--color-shell-sidebar-bottom: #102c30;
--color-shell-sidebar-foreground: #f4f7f7;
--color-shell-sidebar-muted: rgb(255 255 255 / 82%);
--color-shell-accent: #00d2c1;
--color-shell-active-start: rgb(0 188 177 / 40%);
--color-shell-active-end: rgb(0 128 130 / 45%);
--color-shell-navy: #0c2850;
--color-shell-muted-navy: #607895;
--color-shell-border: #d1d9e2;
--color-shell-search-icon: #1d3e6e;
--radius-shell-control: 8px;
--radius-shell-popover: 10px;
--radius-shell: 20px;
--radius-panel: 12px;
--spacing-shell-sidebar: 228px;
--spacing-shell-sidebar-inset: 20px;
--spacing-shell-sidebar-collapsed: 72px;
--spacing-shell-header: 88px;
--spacing-shell-search: 41px;
--spacing-shell-navigation: 48px;
--spacing-shell-avatar: 35px;
--shadow-shell-popover: 0 3px 12px rgb(12 40 80 / 8%);
}

View File

@ -5,10 +5,11 @@ import {
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components/select";
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useId, useState } from "react";
import { useTranslation } from "react-i18next";
export function LanguageSwitcher() {
export function LanguageSwitcher({ className }: { className?: string }) {
const { t, i18n } = useTranslation("core");
const languageOptions = ["es", "en"].map((value) => ({
value,
@ -18,7 +19,7 @@ export function LanguageSwitcher() {
const languageLabelId = useId();
return (
<div className="desk-language">
<div className={cn("mt-4", className)}>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span id={languageLabelId}>{t("language")}</span>
<Select
@ -31,7 +32,7 @@ export function LanguageSwitcher() {
i18n
.changeLanguage(language)
.then(() => {
document.documentElement.lang = i18n.resolvedLanguage ?? "es";
document.documentElement.lang = language;
})
.catch(() => setLanguageError(true));
}}

View File

@ -142,7 +142,7 @@ header global y contenido con los destinos y estado de navegación existentes. E
header solo aparece en sesiones autenticadas. Los catálogos se seleccionan en la
sidebar; la navegación interna de cada compañía permanece en `CompanyDetail`.
Los tokens `--desk-*` de `apps/web/src/global.css` aíslan la paleta del shell de los
Los tokens Tailwind 4 `--color-shell-*` de `apps/web/src/global.css` aíslan la paleta del shell de los
colores semánticos del resto del producto: superficie negra exterior, contenido
#F7F9FB, sidebar #0E282C, acento turquesa, texto y borde azul, radios 8/10/20px,
sidebar 228px, header 88px, búsqueda 41px, fila de navegación 48px y avatar 35px.
@ -163,5 +163,50 @@ separado, manteniendo Configuración y el control de anchura en el pie visible.
En móvil se conserva la navegación horizontal existente.
El menú expandido y su pie comparten márgenes horizontales de 20px mediante
`--desk-sidebar-inset`. El header global usa `--desk-header-background` (#FFFFFF),
con un borde inferior de 1px en `--desk-border`, sobre el contenido #F7F9FB.
`--spacing-shell-sidebar-inset` y la utilidad `px-shell-sidebar-inset`. El header global usa
`bg-shell-header` (#FFFFFF), con un borde inferior `border-shell-border`, sobre el contenido #F7F9FB.
### Estilos con Tailwind 4
La aplicación y la biblioteca base utilizan Tailwind 4.2.2 y el plugin oficial de Vite.
El CSS de la aplicación contiene únicamente imports, fuentes de detección y tokens `@theme`;
los layouts, estados y reglas responsive se expresan mediante utilidades en sus componentes.
No hay clases globales de componente `desk-*`, `company-space-*`, `dashboard-*` ni de inventario.
La implementación vendor de `@repo/shadcn-ui` no se modifica para personalizar el shell.
Los colores propios utilizan `--color-shell-*`; las dimensiones, `--spacing-shell-*`;
los radios, `--radius-shell-*` y `--radius-panel`; la sombra del desplegable,
`--shadow-shell-popover`. El resto de la interfaz conserva los tokens semánticos de shadcn/ui.
Los valores puntuales que no se repiten se declaran como utilidades arbitrarias, no como
tokens o abstracciones anticipadas. Los degradados conservan su interpolación CSS original.
Los breakpoints de la adaptación se declaran en píxeles para conservar los cortes anteriores:
`shell` (768px), `inventory` (1100px) y `dashboard` (1200px). Se usan consistentemente dentro
de cada layout para evitar conflictos de orden entre variantes con distintas unidades.
`WorkspaceNavigationButton` compone Button y comparte los niveles principal y de catálogo,
selección, foco y contracción. `DashboardNavigationButton`, `DashboardStateBadge` y
`DashboardTable` encapsulan patrones repetidos del dashboard; este último reutiliza Table.
Cada componente vive en su propio fichero y recibe acciones, textos o contenido del consumidor.
`LanguageSwitcher` permite ajustar su margen mediante `className`, sin selectores globales
dependientes de la cabecera que lo contiene.
## Avatar de iniciales
`InitialsAvatar` (`modules/companies/src/web/components/initials-avatar.tsx`) es un
componente visual sin dependencias de compañías, DTO ni consultas. Recibe `name`
y atributos de span; admite `className` para tamaño y `aria-hidden` cuando el
control contenedor ya tiene nombre accesible. Se reutiliza en las compañías de
la sidebar y en el perfil del header.
Muestra la primera inicial de hasta dos palabras; conserva siglas de una palabra
como IBM (hasta tres caracteres), y usa «?» para nombres vacíos. El fondo pastel
se elige de una paleta de doce colores mediante un hash del nombre normalizado:
mayúsculas, espacios y acentos no cambian el color. El color no depende del orden
ni cambia al recargar; distintos nombres pueden compartir color. El texto oscuro
mantiene contraste legible. Los avatares de compañías siguen visibles cuando se
contrae el menú y conservan la selección y las ayudas del botón de navegación.
## Avatar y logotipo de compañía
`CompanyAvatar`, `CompanyIdentity` y `CompanyLogoField` reúnen la presentación y selección del logotipo. Su contrato, límites y persistencia se documentan en [Logotipos de compañías](logotipos-de-companias.md). Se usan tamaños de 24–28 px en navegación/listas y 40–56 px en detalle/formulario.

View File

@ -0,0 +1,20 @@
# Logotipos de compañías
El formulario de alta y edición permite seleccionar, sustituir y quitar el logotipo al guardar. Sin logotipo, o si su descarga falla, se mantienen las iniciales y colores deterministas.
## Límites y almacenamiento
`COMPANY_LOGO_LIMITS` centraliza los límites: PNG, JPEG y WebP estáticos, hasta 2 MiB y 4096 × 4096 píxeles. El servidor verifica la firma y decodificación real, rechaza animaciones y limita el tiempo de procesamiento. Sharp normaliza orientación, elimina metadatos y genera WebP de hasta 128 × 128 px y 64 KiB, conservando proporciones y transparencia sin recortar. Sharp es una dependencia nueva justificada por la validación real y transformación segura en servidor; las APIs nativas de Node no ofrecen esta operación.
La migración idempotente añade `companies.logoVersion` y `company_logos`. Solo se conserva la imagen optimizada; el original se descarta. Una transacción actualiza imagen y versión. No se incorporan binarios ni base64 a las respuestas de listados. Las imágenes requieren la sesión existente y las escrituras conservan la protección CSRF/origen de la aplicación. La URL lleva el hash de contenido; caché privada y ETag permiten reutilizarla sin mostrar versiones antiguas tras una sustitución.
Los datos generales y el logotipo se guardan en dos peticiones. Si falla la segunda, el formulario informa del guardado parcial y conserva el identificador y el archivo para reintentar sin duplicar la compañía.
## Componentes
- `CompanyAvatar`: logotipo con carga diferida y dimensiones reservadas; iniciales como alternativa. Usa la consulta compartida de compañías cuando un listado solo aporta identificador y nombre.
- `CompanyIdentity`: avatar y nombre alineados, utilizado en directorio, dashboard, tickets, intervenciones, búsqueda y detalle. El tamaño se ajusta mediante clases existentes.
- `CompanySelect`: opciones y selección de compañías con avatar, reutilizando los controles shadcn/ui y conservando etiquetas de texto para búsquedas por teclado.
- `CompanyLogoField`: selección con validación local de peso y MIME, mensajes accesibles y acciones de quitar/deshacer. No decodifica el original en el navegador; la imagen optimizada aparece después de guardar.
Se conserva la estructura de navegación, el shell y los componentes shadcn/ui existentes, sin modificar código vendor.

View File

@ -34,7 +34,10 @@ export function AssetSelectionPanel({
return null;
}
return (
<aside aria-label={t("assetPanel")} className="asset-side-panel">
<aside
aria-label={t("assetPanel")}
className="hidden min-w-0 grid-cols-[minmax(0,1fr)] gap-6 rounded-panel border border-border bg-muted p-5 [overflow-wrap:anywhere] inventory:grid"
>
<header className="flex items-start justify-between gap-3">
<h2 className="min-w-0 break-words text-lg font-semibold">{asset.values.name}</h2>
<Button aria-label={t("closeAssetPanel")} onClick={onClose} size="sm" variant="ghost">

View File

@ -62,8 +62,12 @@ export function AssetInventoryPage({
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
});
return (
<div className="inventory-layout" data-detail-open={detailOpen && Boolean(selected)}>
<div className="inventory-list space-y-5">
<div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
data-detail-open={detailOpen && Boolean(selected)}
data-slot="inventory-layout"
>
<div className="min-w-0 space-y-5">
<EntityListHeader
actions={
<>
@ -71,7 +75,7 @@ export function AssetInventoryPage({
<div className="flex flex-wrap items-center justify-end gap-2">
{!detailOpen && (
<Button
className="asset-desktop-action"
className="hidden inventory:inline-flex"
onClick={() => setDetailOpen(true)}
variant="outline"
>
@ -112,7 +116,7 @@ export function AssetInventoryPage({
render: (asset) => (
<button
aria-pressed={selected?.id === asset.id && detailOpen}
className="inventory-asset-name text-left font-medium"
className="cursor-pointer text-left font-medium [overflow-wrap:anywhere] hover:underline focus-visible:rounded-[4px] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
onClick={() => {
setSelectedId(asset.id);
setDetailOpen(true);
@ -177,7 +181,7 @@ export function AssetInventoryPage({
{" "}
<div className="contents">
<EntityAction
className="asset-mobile-action"
className="inline-flex inventory:hidden"
onClick={() => setViewId(asset.id)}
>
{t("viewAsset")}
@ -241,5 +245,7 @@ export function AssetInventoryPage({
}
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
return id === selectedId && detailOpen ? "inventory-asset-selected" : "";
return id === selectedId && detailOpen
? "bg-accent hover:bg-accent [&>[data-pinned=right]]:bg-accent [&>[data-pinned=right]]:group-hover/entity-row:bg-accent [&>[data-pinned=right]]:group-has-aria-expanded/entity-row:bg-accent"
: "";
}

View File

@ -10,33 +10,34 @@
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/interventions.test.tsx"
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/components/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/access.test.tsx src/web/mail/mail.test.tsx src/api/domain/work-profile.test.ts src/web/profiles/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printers/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/applications/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificates/certificate.test.tsx src/web/profiles/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote/remote.test.tsx src/web/processes/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/interventions.test.tsx src/web/components/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/components/company-avatar.test.tsx"
},
"dependencies": {
"@hookform/resolvers": "5.2.2",
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*",
"@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*",
"@repo/shadcn-ui": "workspace:*",
"sequelize": "6.37.8",
"zod": "4.3.6",
"react-hook-form": "7.72.1",
"@hookform/resolvers": "5.2.2",
"@tanstack/react-table": "8.21.3",
"@metis/assets": "workspace:*",
"@metis/resources": "workspace:*"
"react-hook-form": "7.72.1",
"sequelize": "6.37.8",
"sharp": "0.35.5",
"zod": "4.3.6"
},
"devDependencies": {
"@repo/typescript-config": "workspace:*",
"@types/node": "25.6.0",
"@types/express": "4.17.21",
"@types/node": "25.6.0",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3"
},
"peerDependencies": {
"@tanstack/react-query": "5.98.0",
"express": "4.22.1",
"i18next": "26.0.4",
"react": "19.2.5",
"react-dom": "19.2.5",
"i18next": "26.0.4",
"react-i18next": "17.0.2",
"@tanstack/react-query": "5.98.0"
"react-i18next": "17.0.2"
}
}

View File

@ -0,0 +1,58 @@
import { Result } from "@repo/rdx-utils";
import { z } from "zod/v4";
import { COMPANY_LOGO_LIMITS } from "../../common/company-logo";
import { CompanyLogoError } from "../domain/company-logo-error";
export type CompanyLogo = Readonly<{ data: Uint8Array; version: string }>;
export interface ICompanyLogoRepository {
exists(companyId: string): Promise<boolean>;
get(companyId: string): Promise<CompanyLogo | null>;
replace(companyId: string, logo: CompanyLogo | null): Promise<boolean>;
}
export interface ICompanyLogoProcessor {
optimize(data: Uint8Array): Promise<Result<CompanyLogo, CompanyLogoError>>;
}
export class CompanyLogoUseCases {
constructor(
private readonly repository: ICompanyLogoRepository,
private readonly processor: ICompanyLogoProcessor
) {}
async get(companyId: string): Promise<Result<CompanyLogo, CompanyLogoError>> {
if (!z.uuid().safeParse(companyId).success) {
return Result.fail(new CompanyLogoError("INVALID_REQUEST"));
}
const logo = await this.repository.get(companyId);
return logo ? Result.ok(logo) : Result.fail(new CompanyLogoError("LOGO_NOT_FOUND"));
}
async save(
companyId: string,
data: Uint8Array
): Promise<Result<string | null, CompanyLogoError>> {
if (!z.uuid().safeParse(companyId).success) {
return Result.fail(new CompanyLogoError("INVALID_REQUEST"));
}
if (data.length > COMPANY_LOGO_LIMITS.inputBytes) {
return Result.fail(new CompanyLogoError("LOGO_TOO_LARGE"));
}
if (!(await this.repository.exists(companyId))) {
return Result.fail(new CompanyLogoError("COMPANY_NOT_FOUND"));
}
const processed = await this.processor.optimize(data);
if (!processed.isSuccess) {
return Result.fail(processed.error);
}
return (await this.repository.replace(companyId, processed.data))
? Result.ok(processed.data.version)
: Result.fail(new CompanyLogoError("COMPANY_NOT_FOUND"));
}
async remove(companyId: string): Promise<Result<string | null, CompanyLogoError>> {
if (!z.uuid().safeParse(companyId).success) {
return Result.fail(new CompanyLogoError("INVALID_REQUEST"));
}
return (await this.repository.replace(companyId, null))
? Result.ok(null)
: Result.fail(new CompanyLogoError("COMPANY_NOT_FOUND"));
}
}

View File

@ -2,7 +2,12 @@ import type { CompanyDTO, CompanySummaryDTO } from "../../common";
import type { Company } from "../domain/company.aggregate";
import type { AssignmentValues } from "../domain/department-assignment.entity";
export function buildCompanySummary(company: Company): CompanySummaryDTO {
return { id: company.id.toPrimitive(), name: company.name.toPrimitive(), active: company.active };
return {
id: company.id.toPrimitive(),
name: company.name.toPrimitive(),
active: company.active,
logoVersion: company.logoVersion,
};
}
export function buildAssignmentValues(values: AssignmentValues) {
return {

View File

@ -0,0 +1,11 @@
export type CompanyLogoErrorCode =
| "INVALID_REQUEST"
| "COMPANY_NOT_FOUND"
| "LOGO_NOT_FOUND"
| "INVALID_LOGO"
| "LOGO_TOO_LARGE";
export class CompanyLogoError extends Error {
constructor(public readonly code: CompanyLogoErrorCode) {
super(code);
}
}

View File

@ -11,6 +11,7 @@ import type { Site, SiteProps } from "./site.entity";
export type CompanyProps = Readonly<{
name: Name;
logoVersion?: string | null;
active: boolean;
people: readonly Person[];
departments: readonly Department[];
@ -44,6 +45,9 @@ export class Company extends AggregateRoot<CompanyProps> {
get name() {
return this.props.name;
}
get logoVersion() {
return this.props.logoVersion ?? null;
}
get active() {
return this.props.active;
}

View File

@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import sharp from "sharp";
import { COMPANY_LOGO_LIMITS } from "../../common/company-logo";
import { CompanyLogoProcessor } from "./company-logo-processor";
const processor = new CompanyLogoProcessor();
test("PNG, JPEG y WebP se normalizan sin recorte y sin metadatos a un avatar ligero", async () => {
for (const format of ["png", "jpeg", "webp"] as const) {
const input = await sharp({
create: { width: 800, height: 400, channels: 3, background: "#216e9a" },
})
.toFormat(format)
.toBuffer();
const result = await processor.optimize(input);
assert.ok(result.isSuccess);
assert.match(result.data.version, /^[a-f0-9]{64}$/);
assert.ok(result.data.data.length <= COMPANY_LOGO_LIMITS.outputBytes);
const image = sharp(result.data.data);
const metadata = await image.metadata();
assert.equal(metadata.format, "webp");
assert.equal(metadata.width, 128);
assert.equal(metadata.height, 128);
assert.equal(metadata.exif, undefined);
const { data, info } = await image.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
assert.equal(data[3], 0);
assert.ok((data[(64 * info.width + 64) * info.channels + 3] ?? 0) > 0);
const repeat = await processor.optimize(input);
assert.ok(repeat.isSuccess);
assert.equal(repeat.data.version, result.data.version);
}
});
test("se rechazan archivos vacíos, corruptos, SVG y GIF aunque simulen un MIME válido", async () => {
for (const input of [
Buffer.alloc(0),
Buffer.from("not an image"),
Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>'),
Buffer.from("GIF89a"),
Buffer.from([0xff, 0xd8, 0xff]),
]) {
const result = await processor.optimize(input);
assert.equal(result.isSuccess, false);
assert.equal(result.error.code, "INVALID_LOGO");
}
});
test("se rechazan excesos de peso y dimensión antes de almacenar", async () => {
const oversized = await processor.optimize(Buffer.alloc(COMPANY_LOGO_LIMITS.inputBytes + 1));
assert.equal(oversized.isSuccess, false);
assert.equal(oversized.error.code, "LOGO_TOO_LARGE");
const image = await sharp({
create: { width: 4097, height: 1, channels: 3, background: "white" },
})
.png()
.toBuffer();
const large = await processor.optimize(image);
assert.equal(large.isSuccess, false);
assert.equal(large.error.code, "LOGO_TOO_LARGE");
});
test("los PNG animados se rechazan antes de decodificarlos", async () => {
const image = await sharp({ create: { width: 8, height: 8, channels: 3, background: "white" } })
.png()
.toBuffer();
const animationChunk = Buffer.alloc(20);
animationChunk.writeUInt32BE(8, 0);
animationChunk.write("acTL", 4);
const animated = Buffer.concat([image.subarray(0, 33), animationChunk, image.subarray(33)]);
const result = await processor.optimize(animated);
assert.equal(result.isSuccess, false);
assert.equal(result.error.code, "INVALID_LOGO");
});

View File

@ -0,0 +1,86 @@
import { createHash } from "node:crypto";
import { Result } from "@repo/rdx-utils";
import sharp from "sharp";
import { COMPANY_LOGO_LIMITS } from "../../common/company-logo";
import type { CompanyLogo, ICompanyLogoProcessor } from "../application/company-logo.use-cases";
import { CompanyLogoError } from "../domain/company-logo-error";
function supportedSignature(data: Uint8Array): boolean {
const buffer = Buffer.from(data);
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return true;
}
if (buffer.subarray(0, 4).toString() === "RIFF" && buffer.subarray(8, 12).toString() === "WEBP") {
return true;
}
if (!buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
return false;
}
// APNG anuncia animación antes de los píxeles; no se acepta como logotipo estático.
for (let offset = 8; offset + 12 <= buffer.length; ) {
const type = buffer.subarray(offset + 4, offset + 8).toString();
if (type === "acTL") {
return false;
}
if (type === "IDAT") {
return true;
}
offset += buffer.readUInt32BE(offset) + 12;
}
return false;
}
export class CompanyLogoProcessor implements ICompanyLogoProcessor {
async optimize(data: Uint8Array): Promise<Result<CompanyLogo, CompanyLogoError>> {
if (!data.length) {
return Result.fail(new CompanyLogoError("INVALID_LOGO"));
}
if (data.length > COMPANY_LOGO_LIMITS.inputBytes) {
return Result.fail(new CompanyLogoError("LOGO_TOO_LARGE"));
}
if (!supportedSignature(data)) {
return Result.fail(new CompanyLogoError("INVALID_LOGO"));
}
try {
const image = sharp(data, {
limitInputPixels: COMPANY_LOGO_LIMITS.pixels,
failOn: "warning",
}).timeout({ seconds: 5 });
const metadata = await image.metadata();
if (
!(metadata.format && ["jpeg", "png", "webp"].includes(metadata.format)) ||
(metadata.pages ?? 1) > 1 ||
!metadata.width ||
!metadata.height
) {
return Result.fail(new CompanyLogoError("INVALID_LOGO"));
}
if (
metadata.width > COMPANY_LOGO_LIMITS.inputDimension ||
metadata.height > COMPANY_LOGO_LIMITS.inputDimension
) {
return Result.fail(new CompanyLogoError("LOGO_TOO_LARGE"));
}
const output = await image
.rotate()
.resize(COMPANY_LOGO_LIMITS.outputDimension, COMPANY_LOGO_LIMITS.outputDimension, {
fit: "contain",
background: { r: 255, g: 255, b: 255, alpha: 0 },
withoutEnlargement: true,
})
.webp({ quality: 82, effort: 3 })
.toBuffer();
if (output.length > COMPANY_LOGO_LIMITS.outputBytes) {
return Result.fail(new CompanyLogoError("LOGO_TOO_LARGE"));
}
return Result.ok({
data: output,
version: createHash("sha256").update(output).digest("hex"),
});
} catch {
return Result.fail(new CompanyLogoError("INVALID_LOGO"));
}
}
}

View File

@ -0,0 +1,86 @@
import type { Application, RequestHandler } from "express";
import { Router, raw } from "express";
import { z } from "zod/v4";
import { COMPANY_LOGO_LIMITS, CompanyLogoVersionSchema } from "../../../common/company-logo";
import type { CompanyLogoUseCases } from "../../application/company-logo.use-cases";
import type { CompanyLogoErrorCode } from "../../domain/company-logo-error";
export function registerCompanyLogoRoutes(
app: Application,
useCases: CompanyLogoUseCases,
authenticate: RequestHandler
) {
const router = Router();
router.get("/:companyId/logo", (request, response, next) => {
const parsed = z.strictObject({ version: CompanyLogoVersionSchema }).safeParse(request.query);
if (!parsed.success) {
response
.status(400)
.json({ code: "INVALID_REQUEST", requestId: response.getHeader("x-request-id") });
return;
}
useCases
.get(request.params.companyId ?? "")
.then((result) => {
if (!result.isSuccess || result.data.version !== parsed.data.version) {
response.status(404).end();
return;
}
response.setHeader("Content-Type", "image/webp");
response.setHeader("Cache-Control", "private, max-age=86400, immutable");
response.setHeader("ETag", `"${result.data.version}"`);
if (request.headers["if-none-match"] === `"${result.data.version}"`) {
response.status(304).end();
return;
}
response.send(Buffer.from(result.data.data));
})
.catch(next);
});
const send = (response: Parameters<RequestHandler>[1], failure: CompanyLogoErrorCode) =>
response
.status(failure.endsWith("NOT_FOUND") ? 404 : failure === "LOGO_TOO_LARGE" ? 413 : 400)
.json({ code: failure, requestId: response.getHeader("x-request-id") });
router.post(
"/:companyId/logo",
raw({
type: "application/octet-stream",
limit: COMPANY_LOGO_LIMITS.inputBytes,
inflate: false,
}),
(request, response, next) => {
if (!Buffer.isBuffer(request.body)) {
send(response, "INVALID_LOGO");
return;
}
useCases
.save(request.params.companyId ?? "", request.body)
.then((result) => {
if (!result.isSuccess) {
send(response, result.error.code);
return;
}
response.json({ logoVersion: result.data });
})
.catch(next);
}
);
router.post("/:companyId/logo/remove", (request, response, next) => {
if (!z.strictObject({}).safeParse(request.body).success) {
send(response, "INVALID_REQUEST");
return;
}
useCases
.remove(request.params.companyId ?? "")
.then((result) => {
if (!result.isSuccess) {
send(response, result.error.code);
return;
}
response.json({ logoVersion: result.data });
})
.catch(next);
});
app.use("/api/companies", authenticate, router);
}

View File

@ -0,0 +1,26 @@
import type { Sequelize } from "sequelize";
import { DataTypes } from "sequelize";
export async function migrateCompanyLogos(database: Sequelize): Promise<void> {
const query = database.getQueryInterface();
const columns = await query.describeTable("companies");
if (!columns.logoVersion) {
await query.addColumn("companies", "logoVersion", {
type: DataTypes.STRING(64),
allowNull: true,
});
}
const tables = await query.showAllTables();
if (!tables.includes("company_logos")) {
await query.createTable("company_logos", {
companyId: {
type: DataTypes.UUID,
primaryKey: true,
references: { model: "companies", key: "id" },
onDelete: "CASCADE",
},
data: { type: DataTypes.BLOB("medium"), allowNull: false },
version: { type: DataTypes.STRING(64), allowNull: false },
});
}
}

View File

@ -15,7 +15,7 @@ import { Site } from "../../domain/site.entity";
import type { CompanyModel, PersonModel, SiteModel } from "./company.model";
import type { AssignmentModel, AssignmentRevisionModel, DepartmentModel } from "./people.model";
export type CompanyRecord = {
company: InferAttributes<CompanyModel>;
company: Omit<InferAttributes<CompanyModel>, "logoVersion"> & { logoVersion?: string | null };
people: InferAttributes<PersonModel>[];
sites: InferAttributes<SiteModel>[];
departments: InferAttributes<DepartmentModel>[];
@ -32,7 +32,10 @@ export class CompanyPersistenceMapper {
private readonly input = new CompanyInputMapper();
toDomain(record: CompanyRecord): Company {
const id = persisted(this.input.id(record.company.id));
const props = persisted(this.input.company(record.company));
const props = {
...persisted(this.input.company(record.company)),
logoVersion: record.company.logoVersion ?? null,
};
const companyId = (value: string) => {
const parent = persisted(this.input.id(value));
if (!parent.equals(id)) {

View File

@ -1,4 +1,9 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import type {
CreationOptional,
InferAttributes,
InferCreationAttributes,
Sequelize,
} from "sequelize";
import { DataTypes, Model } from "sequelize";
export class CompanyModel extends Model<
@ -8,6 +13,7 @@ export class CompanyModel extends Model<
declare id: string;
declare name: string;
declare active: boolean;
declare logoVersion: CreationOptional<string | null>;
}
export class PersonModel extends Model<
InferAttributes<PersonModel>,
@ -37,6 +43,7 @@ export function initializeCompanyModels(database: Sequelize): void {
id: { type: DataTypes.UUID, primaryKey: true },
name: { type: DataTypes.STRING(100), allowNull: false },
active: { type: DataTypes.BOOLEAN, allowNull: false },
logoVersion: { type: DataTypes.STRING(64), allowNull: true },
},
{ sequelize: database, tableName: "companies", timestamps: false }
);

View File

@ -0,0 +1,55 @@
import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize";
import { DataTypes, Model } from "sequelize";
import type { CompanyLogo, ICompanyLogoRepository } from "../../application/company-logo.use-cases";
import { CompanyModel } from "./company.model";
class CompanyLogoModel extends Model<
InferAttributes<CompanyLogoModel>,
InferCreationAttributes<CompanyLogoModel>
> {
declare companyId: string;
declare data: Buffer;
declare version: string;
}
export class SequelizeCompanyLogoRepository implements ICompanyLogoRepository {
constructor(private readonly database: Sequelize) {
CompanyLogoModel.init(
{
companyId: { type: DataTypes.UUID, primaryKey: true },
data: { type: DataTypes.BLOB("medium"), allowNull: false },
version: { type: DataTypes.STRING(64), allowNull: false },
},
{ sequelize: database, tableName: "company_logos", timestamps: false }
);
}
async exists(companyId: string) {
return Boolean(await CompanyModel.findByPk(companyId, { attributes: ["id"] }));
}
async get(companyId: string): Promise<CompanyLogo | null> {
const row = await CompanyLogoModel.findByPk(companyId);
return row ? { data: row.data, version: row.version } : null;
}
async replace(companyId: string, logo: CompanyLogo | null): Promise<boolean> {
return this.database.transaction(async (transaction) => {
const company = await CompanyModel.findByPk(companyId, {
transaction,
lock: transaction.LOCK.UPDATE,
});
if (!company) {
return false;
}
if (logo) {
await CompanyLogoModel.upsert(
{ companyId, data: Buffer.from(logo.data), version: logo.version },
{ transaction }
);
} else {
await CompanyLogoModel.destroy({ where: { companyId }, transaction });
}
await company.update({ logoVersion: logo?.version ?? null }, { transaction });
return true;
});
}
}

View File

@ -7,6 +7,7 @@ import { ApplicationConfigurationUseCases } from "./application/application-conf
import { CertificateConfigurationUseCases } from "./application/certificate-configuration.use-cases";
import { CompanyUseCases } from "./application/company.use-cases";
import { CompanyInputMapper } from "./application/company-input.mapper";
import { CompanyLogoUseCases } from "./application/company-logo.use-cases";
import type {
IAssetDashboardReader,
IInventoryDashboardReader,
@ -27,10 +28,12 @@ import type { ITicketInventoryService } from "./application/ticket.repository";
import { TicketUseCases } from "./application/ticket.use-cases";
import type { IWorkProfileInventoryService } from "./application/work-profile.repository";
import { WorkProfileUseCases } from "./application/work-profile.use-cases";
import { CompanyLogoProcessor } from "./infrastructure/company-logo-processor";
import { registerAccessRoutes } from "./infrastructure/http/access.routes";
import { registerApplicationConfigurationRoutes } from "./infrastructure/http/application-configuration.routes";
import { registerCertificateConfigurationRoutes } from "./infrastructure/http/certificate-configuration.routes";
import { registerCompanyRoutes } from "./infrastructure/http/company.routes";
import { registerCompanyLogoRoutes } from "./infrastructure/http/company-logo.routes";
import { registerDashboardRoutes } from "./infrastructure/http/dashboard.routes";
import { registerFolderMappingRoutes } from "./infrastructure/http/folder-mapping.routes";
import { registerInterventionRoutes } from "./infrastructure/http/intervention.routes";
@ -51,6 +54,7 @@ import {
import { migrateApplicationConfigurations } from "./infrastructure/persistence/application-configuration-migration";
import { migrateCertificateConfigurations } from "./infrastructure/persistence/certificate-configuration-migration";
import { initializeCompanyModels } from "./infrastructure/persistence/company.model";
import { migrateCompanyLogos } from "./infrastructure/persistence/company-logo-migration";
import { CompanyPersistenceMapper } from "./infrastructure/persistence/company-persistence.mapper";
import { migrateFolderMappings } from "./infrastructure/persistence/folder-mapping-migration";
import { migrateInterventions } from "./infrastructure/persistence/intervention-migration";
@ -66,6 +70,7 @@ import { migrateRemoteConnections } from "./infrastructure/persistence/remote-co
import { SequelizeApplicationConfigurationRepository } from "./infrastructure/persistence/sequelize-application-configuration.repository";
import { SequelizeCertificateConfigurationRepository } from "./infrastructure/persistence/sequelize-certificate-configuration.repository";
import { SequelizeCompanyRepository } from "./infrastructure/persistence/sequelize-company.repository";
import { SequelizeCompanyLogoRepository } from "./infrastructure/persistence/sequelize-company-logo.repository";
import { SequelizeDashboardRepository } from "./infrastructure/persistence/sequelize-dashboard.repository";
import { SequelizeFolderMappingRepository } from "./infrastructure/persistence/sequelize-folder-mapping.repository";
import { SequelizeInterventionRepository } from "./infrastructure/persistence/sequelize-intervention.repository";
@ -88,6 +93,7 @@ export async function createCompanyModule(
legacyResourceWrites = false
) {
await migrateCompanies(database);
await migrateCompanyLogos(database);
await migratePeople(database);
await migrateInterventionTemplates(database);
const interventionTemplateRepository = new SequelizeInterventionTemplateRepository(database);
@ -104,6 +110,10 @@ export async function createCompanyModule(
new SequelizeAccessRepository(database, repository, new AccessPersistenceMapper())
);
const useCases = new CompanyUseCases(repository, new CompanyInputMapper());
const logos = new CompanyLogoUseCases(
new SequelizeCompanyLogoRepository(database),
new CompanyLogoProcessor()
);
return {
initializeDashboard: (assets: IAssetDashboardReader, resources: IInventoryDashboardReader) => {
const dashboard = new DashboardUseCases(
@ -191,6 +201,7 @@ export async function createCompanyModule(
},
register: (app: Parameters<typeof registerCompanyRoutes>[0]) => {
registerInterventionTemplateRoutes(app, interventionTemplates, authenticate);
registerCompanyLogoRoutes(app, logos, authenticate);
registerCompanyRoutes(app, useCases, authenticate);
registerTeleworkAuthorizationRoutes(app, telework, authenticate);
registerAccessRoutes(app, access, authenticate, legacyResourceWrites);

View File

@ -0,0 +1,14 @@
import { z } from "zod/v4";
export const COMPANY_LOGO_LIMITS = {
inputBytes: 2 * 1024 * 1024,
inputDimension: 4096,
pixels: 4096 * 4096,
outputDimension: 128,
outputBytes: 64 * 1024,
} as const;
export const COMPANY_LOGO_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export const CompanyLogoVersionSchema = z.string().regex(/^[a-f0-9]{64}$/);
export const CompanyLogoResponseSchema = z.strictObject({
logoVersion: CompanyLogoVersionSchema.nullable(),
});

View File

@ -6,6 +6,9 @@ export const CompanyResponseSchema = z.strictObject({ company: CompanySchema });
export const CompanyErrorResponseSchema = z.strictObject({
code: z.enum([
"INVALID_REQUEST",
"INVALID_LOGO",
"LOGO_TOO_LARGE",
"LOGO_NOT_FOUND",
"COMPANY_NOT_FOUND",
"CONTACT_NOT_FOUND",
"SITE_NOT_FOUND",

View File

@ -1,5 +1,7 @@
import { z } from "zod/v4";
import { CompanyLogoVersionSchema } from "../../company-logo";
import {
DepartmentAssignmentSchema,
DepartmentSchema,
@ -21,7 +23,10 @@ export const SiteFieldsSchema = z.strictObject({
name: RequiredTextSchema,
address: z.string().max(10000).nullable(),
});
export const CompanySummarySchema = CompanyFieldsSchema.extend({ id: z.uuid() });
export const CompanySummarySchema = CompanyFieldsSchema.extend({
id: z.uuid(),
logoVersion: CompanyLogoVersionSchema.nullable().optional(),
});
export type CompanySummaryDTO = z.infer<typeof CompanySummarySchema>;
export const ContactSchema = PersonSchema;
export type ContactDTO = z.infer<typeof ContactSchema>;

View File

@ -1,5 +1,11 @@
export * from "./application-configurations";
export * from "./certificate-configurations";
export {
COMPANY_LOGO_LIMITS,
COMPANY_LOGO_TYPES,
CompanyLogoResponseSchema,
CompanyLogoVersionSchema,
} from "./company-logo";
export * from "./dashboard";
export * from "./dto/request/access.request.dto";
export * from "./dto/request/company.request.dto";

View File

@ -847,12 +847,25 @@
"SITE_NOT_FOUND": "The site does not belong to this company or is not available.",
"UNAUTHENTICATED": "Your session expired. Sign in again.",
"FORBIDDEN": "The operation could not be authorized. Reload the page.",
"INTERNAL_ERROR": "The operation could not be completed. Try again."
"INTERNAL_ERROR": "The operation could not be completed. Try again.",
"INVALID_LOGO": "Invalid image. Use a non-animated PNG, JPEG or WebP.",
"LOGO_TOO_LARGE": "Logo exceeds the limit: maximum 2 MB and 4096 × 4096 px.",
"LOGO_NOT_FOUND": "The logo is no longer available."
},
"shell": {
"catalogs": "Catalogs",
"tickets": "Tickets",
"interventions": "Interventions"
},
"logo": {
"partialSave": "Company details were saved, but the logo was not. You can fix it and save again without creating another company.",
"label": "Company logo",
"optional": "Optional. Initials appear when no logo is available.",
"help": "PNG, JPEG or WebP. Maximum 2 MB and 4096 × 4096 px. Optimized to WebP up to 128 × 128 px and 64 KB, without cropping. Applied on save.",
"selected": "Selected: {{name}}. It will be optimized on save.",
"removed": "The logo will be removed on save.",
"remove": "Remove logo",
"undo": "Undo logo change"
}
},
"access": {

View File

@ -847,12 +847,25 @@
"SITE_NOT_FOUND": "La sede no pertenece a esta compañía o no está disponible.",
"UNAUTHENTICATED": "La sesión ha caducado. Vuelve a entrar.",
"FORBIDDEN": "No se ha podido autorizar la operación. Recarga la página.",
"INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo."
"INTERNAL_ERROR": "No se ha podido completar la operación. Inténtalo de nuevo.",
"INVALID_LOGO": "La imagen no es válida. Usa PNG, JPEG o WebP sin animación.",
"LOGO_TOO_LARGE": "El logotipo supera el límite: máximo 2 MB y 4096 × 4096 px.",
"LOGO_NOT_FOUND": "El logotipo ya no está disponible."
},
"shell": {
"catalogs": "Catálogos",
"tickets": "Tickets",
"interventions": "Intervenciones"
},
"logo": {
"partialSave": "Los datos de la compañía se han guardado, pero el logotipo no. Puedes corregirlo y volver a guardar sin crear otra compañía.",
"label": "Logotipo de la compañía",
"optional": "Opcional. Se muestran las iniciales cuando no hay logotipo.",
"help": "PNG, JPEG o WebP. Máximo 2 MB y 4096 × 4096 px. Se optimiza a WebP de hasta 128 × 128 px y 64 KB, sin recortar. Se aplica al guardar.",
"selected": "Seleccionado: {{name}}. Se optimizará al guardar.",
"removed": "Se quitará el logotipo al guardar.",
"remove": "Quitar logotipo",
"undo": "Deshacer cambio de logotipo"
}
},
"access": {

View File

@ -0,0 +1,29 @@
import { CompanyErrorResponseSchema, CompanyLogoResponseSchema } from "../../common";
import { CompanyApiError } from "./company-api";
export async function saveCompanyLogo(companyId: string, logo: File | null, csrfToken: string) {
const response = await fetch(`/api/companies/${companyId}/logo${logo ? "" : "/remove"}`, {
method: "POST",
credentials: "same-origin",
headers: {
"x-csrf-token": csrfToken,
"content-type": logo ? "application/octet-stream" : "application/json",
},
body: logo ?? "{}",
});
const json: unknown = await response.json();
if (!response.ok) {
const error = CompanyErrorResponseSchema.safeParse(json);
throw new CompanyApiError(
response.status === 413
? "LOGO_TOO_LARGE"
: error.success
? error.data.code
: "INTERNAL_ERROR"
);
}
return CompanyLogoResponseSchema.parse(json);
}
export function companyLogoUrl(companyId: string, version: string) {
return `/api/companies/${encodeURIComponent(companyId)}/logo?version=${encodeURIComponent(version)}`;
}

View File

@ -18,6 +18,7 @@ import { companyApi, companyKeys } from "../api/company-api";
import { CompanyDetail } from "./company-detail";
import { CompanyEditorDialog } from "./company-editor-dialog";
import { CompanyFeedback } from "./company-feedback";
import { CompanyIdentity } from "./company-identity";
const EMPTY_COMPANIES: CompanySummaryDTO[] = [];
const COMPANY_COLUMNS: ColumnDef<CompanySummaryDTO>[] = [
@ -110,7 +111,11 @@ export function CompaniesPage({
onClick={() => selectCompany(company.id)}
type="button"
>
{company.name}
<CompanyIdentity
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</button>
),
},

View File

@ -40,7 +40,10 @@ export function CompaniesWorkspace({
const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" });
return (
<div className="desk-workspace">
<div
className="flex min-h-dvh flex-col overflow-hidden bg-shell-exterior shell:h-dvh shell:min-h-0 shell:flex-row shell:rounded-shell"
data-slot="workspace"
>
<WorkspaceSidebar
catalog={catalog}
companies={companies}
@ -50,7 +53,7 @@ export function CompaniesWorkspace({
onSelectCompany={selectCompany}
onSettings={() => setSettingsOpen(true)}
/>
<div className="desk-workspace-content">
<div className="min-w-0 flex-1 overflow-y-auto bg-shell-content">
<WorkspaceHeader
accountNavigation={accountNavigation}
companies={companies}
@ -97,19 +100,17 @@ export function CompaniesWorkspace({
onBack={() => setLocation({ area: "directory" })}
/>
) : (
<div className="desk-global-content">
<main className="desk-main-content">
<WorkspaceContent
area={current.area}
catalog={catalog}
csrfToken={csrfToken}
key={JSON.stringify(current)}
navigation={current}
onNavigate={setLocation}
onSelectCompany={selectCompany}
/>
</main>
</div>
<main className="min-w-0 p-4 shell:p-6">
<WorkspaceContent
area={current.area}
catalog={catalog}
csrfToken={csrfToken}
key={JSON.stringify(current)}
navigation={current}
onNavigate={setLocation}
onSelectCompany={selectCompany}
/>
</main>
)}
</div>
</div>

View File

@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderToStaticMarkup } from "react-dom/server";
import { companyKeys } from "../api/company-api";
import { CompanyAvatar } from "./company-avatar";
const companyId = "0199b200-0000-7000-8000-000000000001";
const version = "a".repeat(64);
test("el logo sustituye iniciales con URL versionada, dimensiones fijas y carga diferida", () => {
const client = new QueryClient();
const markup = renderToStaticMarkup(
<QueryClientProvider client={client}>
<CompanyAvatar companyId={companyId} logoVersion={version} name="Acme" />
</QueryClientProvider>
);
assert.ok(markup.includes(`/api/companies/${companyId}/logo?version=${version}`));
assert.ok(markup.includes('loading="lazy"'));
assert.ok(markup.includes('width="128"'));
assert.ok(markup.includes('aria-label="Acme"'));
assert.equal(markup.includes("initials-avatar"), false);
client.clear();
});
test("sin logotipo aparecen iniciales y las listas consultan la misma caché de compañías", () => {
const client = new QueryClient();
client.setQueryData(companyKeys.list, {
companies: [{ id: companyId, name: "Acme", active: true, logoVersion: version }],
});
const fromCache = renderToStaticMarkup(
<QueryClientProvider client={client}>
<CompanyAvatar companyId={companyId} name="Acme" />
</QueryClientProvider>
);
assert.ok(fromCache.includes("image") || fromCache.includes("<img"));
assert.ok(fromCache.includes(version));
const initials = renderToStaticMarkup(
<QueryClientProvider client={client}>
<CompanyAvatar companyId={companyId} logoVersion={null} name="Acme" />
</QueryClientProvider>
);
assert.ok(initials.includes("initials-avatar"));
assert.equal(initials.includes("<img"), false);
client.clear();
});

View File

@ -0,0 +1,62 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import type { ComponentProps } from "react";
import { useState } from "react";
import { companyApi, companyKeys } from "../api/company-api";
import { companyLogoUrl } from "../api/company-logo-api";
import { InitialsAvatar } from "./initials-avatar";
export type CompanyAvatarProps = Omit<ComponentProps<"span">, "children"> & {
companyId?: string | undefined;
name: string;
logoVersion?: string | null | undefined;
};
export function CompanyAvatar({
companyId,
name,
logoVersion,
className,
...props
}: CompanyAvatarProps) {
const companies = useQuery({
queryKey: companyKeys.list,
queryFn: companyApi.list,
enabled: Boolean(companyId) && logoVersion === undefined,
});
const version =
logoVersion === undefined
? companies.data?.companies.find((company) => company.id === companyId)?.logoVersion
: logoVersion;
const src = companyId && version ? companyLogoUrl(companyId, version) : null;
const [failedSource, setFailedSource] = useState<string | null>(null);
return (
<span
{...props}
aria-label={name}
className={cn(
"inline-flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full",
className
)}
data-slot="company-avatar"
role="img"
>
{src && failedSource !== src ? (
// biome-ignore lint/performance/noImgElement: Vite usa la imagen ya optimizada en servidor, de hasta 128 px y 64 KB.
<img
alt=""
className="size-full bg-white object-contain"
decoding="async"
height={128}
loading="lazy"
onError={() => setFailedSource(src)}
src={src}
width={128}
/>
) : (
<InitialsAvatar aria-hidden="true" className="size-full" name={name} />
)}
</span>
);
}

View File

@ -18,6 +18,7 @@ import { companyApi, companyKeys } from "../api/company-api";
import { CompanyEditorDialog } from "./company-editor-dialog";
import { CompanyFeedback } from "./company-feedback";
import { CompanyIdentity } from "./company-identity";
import { CompanySection } from "./company-section";
import type { CompanyTab } from "./company-tab";
@ -50,10 +51,12 @@ export function CompanyDetail({
const company = query.data?.company;
const layout = embedded
? {
root: "company-space",
heading: "company-space-heading",
navigation: "company-space-navigation",
main: "company-space-main",
root: "grid min-h-[calc(100dvh-140px)] grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_1fr_auto] [grid-template-areas:'heading'_'navigation'_'main'_'account'] shell:min-h-[calc(100dvh-var(--spacing-shell-header))] shell:grid-cols-[240px_minmax(0,1fr)] shell:grid-rows-[auto_1fr_auto] shell:[grid-template-areas:'heading_main'_'navigation_main'_'account_main']",
heading:
"flex items-center justify-between gap-3 border-b border-border p-3 [grid-area:heading] shell:block shell:px-4 shell:py-5",
navigation:
"flex flex-row flex-wrap gap-1.5 p-2.5 [grid-area:navigation] shell:flex-col shell:flex-nowrap shell:gap-1 shell:py-4",
main: "min-w-0 border-t border-border bg-background p-4 [grid-area:main] shell:border-l shell:border-t-0 shell:p-6",
}
: {
root: "space-y-6",
@ -81,17 +84,38 @@ export function CompanyDetail({
{t("retry")}
</Button>
)}
{embedded && <footer className="company-space-account">{accountNavigation}</footer>}
{embedded && (
<footer className="border-t border-border p-3 [grid-area:account] shell:px-2.5 shell:py-4">
{accountNavigation}
</footer>
)}
{company && (
<>
<div className={layout.heading}>
<div>
<h1 className="text-2xl font-semibold">{company.name}</h1>
<h1
className={
embedded
? "text-base font-semibold leading-[1.3333333333] [overflow-wrap:anywhere]"
: "text-2xl font-semibold"
}
>
<CompanyIdentity
className={embedded ? "size-10" : "size-12"}
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{t(company.active ? "activeCompany" : "inactiveCompany")}
</p>
</div>
<Button onClick={() => setEditOpen(true)} variant="outline">
<Button
className={embedded ? "shell:mt-4 shell:w-full" : undefined}
onClick={() => setEditOpen(true)}
variant="outline"
>
{t("editCompany")}
</Button>
</div>
@ -134,6 +158,11 @@ export function CompanyDetail({
).map(([section, label, Icon]) => (
<Button
aria-pressed={tab === section}
className={
embedded
? "h-auto min-h-10 justify-start whitespace-normal text-left text-[13px] leading-[1.4285714286] aria-pressed:bg-accent aria-pressed:hover:bg-accent dark:aria-pressed:hover:bg-accent aria-pressed:text-accent-foreground shell:text-sm"
: undefined
}
key={section}
onClick={() => setTab(section)}
variant="ghost"
@ -145,7 +174,16 @@ export function CompanyDetail({
</nav>
<main className={layout.main} key={tab}>
{embedded && <div className="company-space-breadcrumb">{company.name}</div>}
{!embedded && (
<div className="mb-4 border-b border-border pb-4 text-sm leading-[1.5] text-muted-foreground shell:mb-6">
<CompanyIdentity
className="size-6"
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</div>
)}
<CompanySection
company={company}
csrfToken={csrfToken}

View File

@ -5,7 +5,7 @@ import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useId } from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@ -13,8 +13,10 @@ import type { CompanyRequestDTO, CompanySummaryDTO } from "../../common";
import { CompanyRequestSchema } from "../../common";
import { EntityHelp } from "../access/entity-help";
import { companyApi, companyKeys } from "../api/company-api";
import { saveCompanyLogo } from "../api/company-logo-api";
import { CompanyFeedback } from "./company-feedback";
import { CompanyLogoField } from "./company-logo-field";
export function CompanyForm({
company,
@ -32,18 +34,38 @@ export function CompanyForm({
const { t } = useTranslation("companies");
const id = useId();
const client = useQueryClient();
const [savedCompanyId, setSavedCompanyId] = useState(company?.id);
const [partialSave, setPartialSave] = useState(false);
const [logoDraft, setLogoDraft] = useState<File | null | undefined>();
const form = useForm<CompanyRequestDTO>({
resolver: zodResolver(CompanyRequestSchema),
defaultValues: { name: company?.name ?? "", active: company?.active ?? true },
});
const mutation = useMutation({
mutationFn: (values: CompanyRequestDTO) => companyApi.save(company?.id, values, csrfToken),
mutationFn: async (values: CompanyRequestDTO) => {
setPartialSave(false);
const response = await companyApi.save(savedCompanyId, values, csrfToken);
setSavedCompanyId(response.company.id);
client.setQueryData(companyKeys.detail(response.company.id), response);
if (logoDraft !== undefined) {
setPartialSave(true);
const logo = await saveCompanyLogo(response.company.id, logoDraft, csrfToken);
return { ...response, company: { ...response.company, logoVersion: logo.logoVersion } };
}
return response;
},
onError: async () => {
await client.invalidateQueries({ queryKey: companyKeys.list });
},
onSuccess: async (response) => {
setPartialSave(false);
setLogoDraft(undefined);
client.setQueryData(companyKeys.detail(response.company.id), response);
await client.invalidateQueries({ queryKey: companyKeys.list });
if (company) {
form.reset({ name: response.company.name, active: response.company.active });
} else {
setSavedCompanyId(undefined);
form.reset();
onCreated?.(response.company.id);
}
@ -69,6 +91,19 @@ export function CompanyForm({
/>
<Label htmlFor={`${id}-active`}>{t("activeCompany")}</Label>
</div>
<CompanyLogoField
companyId={savedCompanyId}
disabled={mutation.isPending}
draft={logoDraft}
logoVersion={mutation.data ? mutation.data.company.logoVersion : company?.logoVersion}
name={form.watch("name")}
onChange={setLogoDraft}
/>
{mutation.isError && partialSave && (
<p className="text-sm text-muted-foreground" role="status">
{t("logo.partialSave")}
</p>
)}
<CompanyFeedback
error={mutation.error}
invalid={Object.keys(form.formState.errors).length > 0}

View File

@ -0,0 +1,21 @@
import type { CompanyAvatarProps } from "./company-avatar";
import { CompanyAvatar } from "./company-avatar";
export function CompanyIdentity({
companyId,
name,
logoVersion,
className,
}: Pick<CompanyAvatarProps, "companyId" | "name" | "logoVersion" | "className">) {
return (
<span className="inline-flex min-w-0 items-center gap-2 align-middle">
<CompanyAvatar
aria-hidden="true"
className={className}
companyId={companyId}
logoVersion={logoVersion}
name={name}
/>
<span className="truncate">{name}</span>
</span>
);
}

View File

@ -0,0 +1,110 @@
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 { useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { COMPANY_LOGO_LIMITS, COMPANY_LOGO_TYPES } from "../../common";
import { CompanyAvatar } from "./company-avatar";
export function CompanyLogoField({
companyId,
name,
logoVersion,
draft,
onChange,
disabled,
}: {
companyId?: string | undefined;
name: string;
logoVersion?: string | null | undefined;
draft: File | null | undefined;
onChange: (draft: File | null | undefined) => void;
disabled: boolean;
}) {
const { t } = useTranslation("companies");
const id = useId();
const [error, setError] = useState<string | null>(null);
return (
<div className="space-y-2">
<Label htmlFor={id}>{t("logo.label")}</Label>
<div className="flex items-center gap-3">
<CompanyAvatar
aria-hidden="true"
className="size-14"
companyId={companyId}
logoVersion={draft === null ? null : (logoVersion ?? null)}
name={name}
/>
<span className="text-sm text-muted-foreground">
{draft
? t("logo.selected", { name: draft.name })
: draft === null
? t("logo.removed")
: t("logo.optional")}
</span>
</div>
<Input
accept={COMPANY_LOGO_TYPES.join(",")}
aria-describedby={`${id}-help${error ? ` ${id}-error` : ""}`}
aria-invalid={Boolean(error)}
disabled={disabled}
id={id}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
if (!COMPANY_LOGO_TYPES.some((type) => type === file.type)) {
setError(t("errors.INVALID_LOGO"));
return;
}
if (file.size > COMPANY_LOGO_LIMITS.inputBytes || !file.size) {
setError(t("errors.LOGO_TOO_LARGE"));
return;
}
setError(null);
onChange(file);
}}
type="file"
/>
<p className="text-xs text-muted-foreground" id={`${id}-help`}>
{t("logo.help")}
</p>
{error && (
<p className="text-sm text-destructive" id={`${id}-error`} role="alert">
{error}
</p>
)}
{draft !== null && (logoVersion || draft) && (
<Button
disabled={disabled}
onClick={() => {
setError(null);
onChange(null);
}}
size="sm"
type="button"
variant="outline"
>
{t("logo.remove")}
</Button>
)}
{draft !== undefined && (
<Button
disabled={disabled}
onClick={() => {
setError(null);
onChange(undefined);
}}
size="sm"
type="button"
variant="ghost"
>
{t("logo.undo")}
</Button>
)}
</div>
);
}

View File

@ -0,0 +1,87 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { ButtonGroup } from "@repo/shadcn-ui/components/button-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@repo/shadcn-ui/components/select";
import type { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import type { ComponentProps } from "react";
import { CompanyIdentity } from "./company-identity";
export function CompanySelect({
id,
label,
options,
placeholder,
value,
onValueChange,
disabled = false,
action,
required = false,
invalid = false,
describedBy,
}: ComponentProps<typeof SelectWithAction>) {
const selected = options.find((option) => option.value === value);
const select = (
<Select
disabled={disabled}
items={[{ value: "", label: placeholder }, ...options]}
onValueChange={(next) => {
if (typeof next === "string") {
onValueChange(next);
}
}}
required={required}
value={value}
>
<SelectTrigger
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
aria-label={label}
className="w-full min-w-0 flex-1"
id={id}
>
<SelectValue>
{selected ? (
<CompanyIdentity className="size-5" companyId={selected.value} name={selected.label} />
) : (
placeholder
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="">{placeholder}</SelectItem>
{options.map((option) => (
<SelectItem disabled={option.disabled} key={option.value} value={option.value}>
<CompanyIdentity className="size-6" companyId={option.value} name={option.label} />
</SelectItem>
))}
</SelectContent>
</Select>
);
if (!action) {
return select;
}
return (
<ButtonGroup aria-label={label} className="w-full min-w-0">
{select}
<Button
aria-label={action.label}
disabled={disabled || action.disabled}
onClick={action.onClick}
size="icon"
title={action.label}
type="button"
variant="outline"
>
<span aria-hidden="true" className="text-lg leading-none">
+
</span>
</Button>
</ButtonGroup>
);
}

View File

@ -2,10 +2,12 @@ import { ClockIcon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "./company-identity";
import type { DashboardNavigation } from "./dashboard-api";
import { dashboardApi, dashboardKeys, entryDestination } from "./dashboard-api";
import { BlockState } from "./dashboard-block";
import { KIND_ICONS } from "./dashboard-kind-icons";
import { DashboardNavigationButton } from "./dashboard-navigation-button";
import { DashboardPanel } from "./dashboard-panel";
export function DashboardActivityPanel({
companyId,
@ -36,28 +38,27 @@ export function DashboardActivityPanel({
emptyText={t("dashboard.noRecentActivity")}
query={activity}
>
<ol className="dashboard-timeline">
<ol className="grid gap-5">
{activity.data?.items.slice(0, 4).map((item) => {
const Icon = KIND_ICONS[item.kind];
return (
<li key={item.eventId ?? item.id}>
<li
className="grid grid-cols-[24px_minmax(0,1fr)] items-start gap-3 text-sm leading-[1.5] shell:grid-cols-[110px_24px_minmax(0,1fr)]"
key={item.eventId ?? item.id}
>
<time
className="text-xs text-muted-foreground"
className="col-span-full text-xs text-muted-foreground shell:col-span-1"
dateTime={item.recordedAt ?? undefined}
>
{date(item.recordedAt)}
</time>
<Icon aria-hidden="true" className="size-5 text-primary shrink-0" />
<div>
<button
className="dashboard-link"
onClick={() => onNavigate(entryDestination(item))}
type="button"
>
<DashboardNavigationButton onClick={() => onNavigate(entryDestination(item))}>
{t("dashboard.recordUpdated", { name: item.reference ?? item.name })}
</button>
</DashboardNavigationButton>
<p className="text-xs text-muted-foreground">
{item.companyName}
<CompanyIdentity companyId={item.companyId} name={item.companyName} />
{item.actorName ? ` · ${item.actorName}` : ""}
</p>
</div>

View File

@ -53,19 +53,21 @@ export function DashboardAttentionPanel({
return (
<li key={item.kind}>
<button
className="dashboard-alert"
className="flex w-full items-center gap-4 py-4 text-left text-sm leading-[1.5] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-ring"
onClick={() => onDetails(item.kind)}
type="button"
>
<Icon aria-hidden="true" className="size-5" />
<strong
className={
item.count ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
item.count
? "text-xl leading-[1.5] text-amber-600 dark:text-amber-400"
: "text-xl leading-[1.5] text-muted-foreground"
}
>
{item.count}
</strong>
<span>{t(`dashboard.issues.${item.kind}`)}</span>
<span className="flex-1">{t(`dashboard.issues.${item.kind}`)}</span>
<ChevronRightIcon aria-hidden="true" className="size-4 shrink-0" />
</button>
</li>

View File

@ -2,10 +2,13 @@ import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "./company-identity";
import type { DashboardNavigation } from "./dashboard-api";
import { dashboardApi, dashboardKeys } from "./dashboard-api";
import { BlockState } from "./dashboard-block";
import { DashboardNavigationButton } from "./dashboard-navigation-button";
import { DashboardPanel } from "./dashboard-panel";
import { DashboardTable } from "./dashboard-table";
export function DashboardCompaniesPanel({
companyId,
onDetails,
@ -29,99 +32,86 @@ export function DashboardCompaniesPanel({
title={t("dashboard.companySummary")}
>
<BlockState empty={!totals.data?.items.length} emptyText={t("noCompanies")} query={totals}>
<div className="overflow-x-auto">
<table>
<caption className="sr-only">{t("dashboard.companySummary")}</caption>
<thead>
<tr>
{[
t("tickets.company"),
t("dashboard.openTickets"),
t("dashboard.pendingInterventions"),
t("dashboard.registeredAssets"),
t("people"),
].map((label) => (
<th key={label} scope="col">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{totals.data?.items.slice(0, 5).map((item) => (
<tr key={item.id}>
<td>
<button
className="dashboard-link"
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
type="button"
>
{item.name}
</button>
</td>
<td>
<button
aria-label={`${t("dashboard.openTickets")} · ${item.name}: ${item.tickets}`}
className="dashboard-link text-destructive"
onClick={() =>
onNavigate({ area: "tickets", companyId: item.id, pendingOnly: true })
}
type="button"
>
{item.tickets}
</button>
</td>
<td>
<button
aria-label={`${t("dashboard.pendingInterventions")} · ${item.name}: ${item.interventions}`}
className="dashboard-link text-amber-600 dark:text-amber-400"
onClick={() =>
onNavigate({
area: "interventions",
companyId: item.id,
pendingOnly: true,
})
}
type="button"
>
{item.interventions}
</button>
</td>
<td>
<button
className="dashboard-link"
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
type="button"
>
<span className="sr-only">
{t("dashboard.registeredAssets")} · {item.name}:{" "}
</span>
{item.assets}
</button>
</td>
<td>
<button
className="dashboard-link"
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "people" })
}
type="button"
>
<span className="sr-only">
{t("people")} · {item.name}:{" "}
</span>
{item.people}
</button>
</td>
</tr>
<DashboardTable caption={t("dashboard.companySummary")}>
<thead>
<tr>
{[
t("tickets.company"),
t("dashboard.openTickets"),
t("dashboard.pendingInterventions"),
t("dashboard.registeredAssets"),
t("people"),
].map((label) => (
<th key={label} scope="col">
{label}
</th>
))}
</tbody>
</table>
</div>
</tr>
</thead>
<tbody>
{totals.data?.items.slice(0, 5).map((item) => (
<tr key={item.id}>
<td>
<DashboardNavigationButton
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
>
<CompanyIdentity companyId={item.id} name={item.name} />
</DashboardNavigationButton>
</td>
<td>
<DashboardNavigationButton
aria-label={`${t("dashboard.openTickets")} · ${item.name}: ${item.tickets}`}
className="text-destructive"
onClick={() =>
onNavigate({ area: "tickets", companyId: item.id, pendingOnly: true })
}
>
{item.tickets}
</DashboardNavigationButton>
</td>
<td>
<DashboardNavigationButton
aria-label={`${t("dashboard.pendingInterventions")} · ${item.name}: ${item.interventions}`}
className="text-amber-600 dark:text-amber-400"
onClick={() =>
onNavigate({
area: "interventions",
companyId: item.id,
pendingOnly: true,
})
}
>
{item.interventions}
</DashboardNavigationButton>
</td>
<td>
<DashboardNavigationButton
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
>
<span className="sr-only">
{t("dashboard.registeredAssets")} · {item.name}:{" "}
</span>
{item.assets}
</DashboardNavigationButton>
</td>
<td>
<DashboardNavigationButton
onClick={() => onNavigate({ area: "company", id: item.id, section: "people" })}
>
<span className="sr-only">
{t("people")} · {item.name}:{" "}
</span>
{item.people}
</DashboardNavigationButton>
</td>
</tr>
))}
</tbody>
</DashboardTable>
</BlockState>
</DashboardPanel>
);

View File

@ -8,9 +8,12 @@ import { useTranslation } from "react-i18next";
import type { DashboardIssue } from "../../common/dashboard";
import { CompanyIdentity } from "./company-identity";
import type { DashboardNavigation } from "./dashboard-api";
import { dashboardApi, dashboardKeys, entryDestination } from "./dashboard-api";
import { BlockState } from "./dashboard-block";
import { DashboardNavigationButton } from "./dashboard-navigation-button";
import { DashboardTable } from "./dashboard-table";
export function DashboardDetailsDialog({
companyId,
@ -89,15 +92,13 @@ export function DashboardDetailsDialog({
label: t("name"),
value: (item) => item.name,
render: (item) => (
<button
className="dashboard-link"
<DashboardNavigationButton
onClick={() =>
onNavigate({ area: "company", id: item.id, section: "inventory" })
}
type="button"
>
{item.name}
</button>
<CompanyIdentity companyId={item.id} name={item.name} />
</DashboardNavigationButton>
),
},
{
@ -139,9 +140,8 @@ export function DashboardDetailsDialog({
query={query}
>
<p className="text-sm text-muted-foreground">{range}</p>
<div className="overflow-x-auto dashboard-panel !p-0 !border-0 !shadow-none">
<table>
<caption className="sr-only">{title}</caption>
<div className="rounded-panel bg-card">
<DashboardTable caption={title}>
<thead>
<tr>
<th scope="col">{t("name")}</th>
@ -155,16 +155,16 @@ export function DashboardDetailsDialog({
{query.data?.items.map((item) => (
<tr key={item.eventId ?? item.id}>
<td>
<button
className="dashboard-link"
<DashboardNavigationButton
onClick={() => onNavigate(entryDestination(item))}
type="button"
>
{item.reference ? `${item.reference} · ` : ""}
{item.name}
</button>
</DashboardNavigationButton>
</td>
<td>
<CompanyIdentity companyId={item.companyId} name={item.companyName} />
</td>
<td>{item.companyName}</td>
{details.activity && (
<td>
{item.recordedAt
@ -187,7 +187,7 @@ export function DashboardDetailsDialog({
</tr>
))}
</tbody>
</table>
</DashboardTable>
</div>
</BlockState>
{!details.activity && query.isSuccess && (

View File

@ -0,0 +1,18 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentProps } from "react";
export function DashboardNavigationButton({
className,
...props
}: Omit<ComponentProps<"button">, "type">) {
return (
<button
{...props}
className={cn(
"text-left font-semibold text-primary hover:underline focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-ring",
className
)}
type="button"
/>
);
}

View File

@ -1,5 +1,4 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Building2Icon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import type { ComponentProps } from "react";
@ -8,6 +7,7 @@ import { useTranslation } from "react-i18next";
import { companyApi, companyKeys } from "../api/company-api";
import { CompanySelect } from "./company-select";
import { DashboardActivityPanel } from "./dashboard-activity-panel";
import type { DashboardNavigation } from "./dashboard-api";
import { dashboardApi, dashboardKeys } from "./dashboard-api";
@ -41,14 +41,14 @@ export function DashboardPage({
});
return (
<div className="dashboard-page space-y-6">
<div className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold tracking-tight">{t("dashboard.title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("dashboard.description")}</p>
</div>
<div className="w-full sm:w-64 space-y-2">
<SelectWithAction
<CompanySelect
id="dashboard-company"
label={t("tickets.company")}
onValueChange={setCompanyId}

View File

@ -16,9 +16,12 @@ export function DashboardPanel({
}) {
const { t } = useTranslation("companies");
return (
<section aria-label={title} className="dashboard-panel min-w-0">
<section
aria-label={title}
className="min-w-0 rounded-panel border border-border bg-card p-4 shadow-[0_1px_2px_color-mix(in_srgb,var(--foreground)_5%,transparent)] shell:p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-3">
<h2 className="flex items-center gap-3 text-base font-semibold">
{icon && (
<span aria-hidden="true" className="[&_svg]:size-5">
{icon}

View File

@ -0,0 +1,25 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ReactNode } from "react";
export function DashboardStateBadge({
state,
children,
}: {
state: string | null;
children: ReactNode;
}) {
return (
<span
className={cn(
"inline-block whitespace-nowrap rounded-[999px] bg-muted px-2.5 py-[3px] text-xs font-semibold leading-[1.5]",
(state === "urgent" || state === "high") &&
"bg-[color-mix(in_srgb,var(--destructive)_12%,var(--card))] text-destructive",
state === "in_progress" &&
"bg-[color-mix(in_srgb,var(--primary)_12%,var(--card))] text-primary"
)}
data-state={state}
>
{children}
</span>
);
}

View File

@ -67,7 +67,11 @@ export function DashboardStatistics({
},
] as const;
return (
<section aria-label={t("dashboard.indicators")} className="dashboard-kpis">
<section
aria-label={t("dashboard.indicators")}
className="grid grid-cols-2 gap-3 shell:grid-cols-3 shell:gap-4 dashboard:grid-cols-5"
data-slot="dashboard-statistics"
>
{kpis.map(({ key, label, Icon, tone, action }) => (
<StatisticCard
icon={<Icon />}

View File

@ -0,0 +1,11 @@
import { Table } from "@repo/shadcn-ui/components/table";
import type { ReactNode } from "react";
export function DashboardTable({ caption, children }: { caption: string; children: ReactNode }) {
return (
<Table className="w-full border-collapse text-[13px] [&_th]:border-b [&_th]:border-border [&_th]:px-2 [&_th]:py-2.5 [&_th]:text-left [&_th]:align-top [&_th]:font-medium [&_th]:text-muted-foreground [&_td]:border-b [&_td]:border-border [&_td]:px-2 [&_td]:py-2.5 [&_td]:text-left [&_td]:align-top">
<caption className="sr-only">{caption}</caption>
{children}
</Table>
);
}

View File

@ -2,11 +2,15 @@ import { TicketIcon } from "@repo/shadcn-ui/lib/icons";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "./company-identity";
import type { DashboardNavigation } from "./dashboard-api";
import { dashboardApi, dashboardKeys, entryDestination } from "./dashboard-api";
import { BlockState } from "./dashboard-block";
import { KIND_ICONS } from "./dashboard-kind-icons";
import { DashboardNavigationButton } from "./dashboard-navigation-button";
import { DashboardPanel } from "./dashboard-panel";
import { DashboardStateBadge } from "./dashboard-state-badge";
import { DashboardTable } from "./dashboard-table";
export function DashboardWorkPanel({
companyId,
onDetails,
@ -30,68 +34,63 @@ export function DashboardWorkPanel({
emptyText={t("dashboard.noPendingWork")}
query={work}
>
<div className="overflow-x-auto">
<table>
<caption className="sr-only">{t("dashboard.pendingWork")}</caption>
<thead>
<tr>
{[
t("dashboard.type"),
t("tickets.reference"),
t("tickets.subject"),
t("tickets.company"),
t("tickets.priority"),
t("tickets.state"),
].map((label) => (
<th key={label} scope="col">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{work.data?.items.slice(0, 6).map((item) => {
const Icon = KIND_ICONS[item.kind];
return (
<tr key={item.id}>
<td>
<Icon aria-hidden="true" className="size-5 text-primary" />
<span className="sr-only">{t(`dashboard.types.${item.kind}`)}</span>
</td>
<td>
<button
className="dashboard-link"
onClick={() => onNavigate(entryDestination(item))}
type="button"
>
{item.reference}
</button>
</td>
<td>{item.name}</td>
<td>{item.companyName}</td>
<td>
{item.priority ? (
<span className="dashboard-badge" data-state={item.priority}>
{t(`tickets.priorities.${item.priority}`)}
</span>
) : (
"—"
<DashboardTable caption={t("dashboard.pendingWork")}>
<thead>
<tr>
{[
t("dashboard.type"),
t("tickets.reference"),
t("tickets.subject"),
t("tickets.company"),
t("tickets.priority"),
t("tickets.state"),
].map((label) => (
<th key={label} scope="col">
{label}
</th>
))}
</tr>
</thead>
<tbody>
{work.data?.items.slice(0, 6).map((item) => {
const Icon = KIND_ICONS[item.kind];
return (
<tr key={item.id}>
<td>
<Icon aria-hidden="true" className="size-5 text-primary" />
<span className="sr-only">{t(`dashboard.types.${item.kind}`)}</span>
</td>
<td>
<DashboardNavigationButton onClick={() => onNavigate(entryDestination(item))}>
{item.reference}
</DashboardNavigationButton>
</td>
<td>{item.name}</td>
<td>
<CompanyIdentity companyId={item.companyId} name={item.companyName} />
</td>
<td>
{item.priority ? (
<DashboardStateBadge state={item.priority}>
{t(`tickets.priorities.${item.priority}`)}
</DashboardStateBadge>
) : (
"—"
)}
</td>
<td>
<DashboardStateBadge state={item.state}>
{t(
(item.kind === "ticket" ? "tickets.states." : "interventions.states.") +
item.state
)}
</td>
<td>
<span className="dashboard-badge" data-state={item.state}>
{t(
(item.kind === "ticket" ? "tickets.states." : "interventions.states.") +
item.state
)}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</DashboardStateBadge>
</td>
</tr>
);
})}
</tbody>
</DashboardTable>
</BlockState>
</DashboardPanel>
);

View File

@ -0,0 +1,42 @@
const AVATAR_BACKGROUNDS = [
"#B9DED4",
"#C6D8F0",
"#D7C9ED",
"#EEC8D4",
"#EBCDB6",
"#E4DBAB",
"#CADDAD",
"#ADDCDD",
"#BCD0E6",
"#DCC8DA",
"#D9D4BF",
"#C3DCCD",
] as const;
/** Colores estables al normalizar mayúsculas, espacios y acentos del nombre. */
export function getInitialsAvatarAppearance(name: string) {
const displayName = name.normalize("NFC").trim().replace(/\s+/gu, " ");
const words = displayName.match(/[\p{L}\p{N}]+/gu) ?? [];
const firstWord = words[0] ?? "";
const initials =
words.length === 1 &&
firstWord === firstWord.toUpperCase() &&
firstWord !== firstWord.toLowerCase() &&
Array.from(firstWord).length <= 3
? firstWord
: words
.slice(0, 2)
.map((word) => Array.from(word)[0] ?? "")
.join("")
.toUpperCase();
const colorKey = displayName.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
let hash = 0;
for (const character of colorKey) {
hash = (Math.imul(hash, 31) + (character.codePointAt(0) ?? 0)) >>> 0;
}
return {
initials: initials || "?",
background: AVATAR_BACKGROUNDS[hash % AVATAR_BACKGROUNDS.length] ?? AVATAR_BACKGROUNDS[0],
foreground: "#223C4B",
};
}

View File

@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { renderToStaticMarkup } from "react-dom/server";
import { InitialsAvatar } from "./initials-avatar";
import { getInitialsAvatarAppearance } from "./initials-avatar-appearance";
test("iniciales y siglas soportan nombres vacíos, Unicode y separadores", () => {
for (const [name, expected] of [
["Compañía A", "CA"],
["Compañía B", "CB"],
["Árbol Azul", "ÁA"],
["IBM", "IBM"],
["Acme", "A"],
[" ", "?"],
["東京", "東"],
["Sol-Luna", "SL"],
]) {
assert.equal(getInitialsAvatarAppearance(name ?? "").initials, expected);
}
});
test("el color es estable con variaciones de espacios, mayúsculas y acentos", () => {
const reference = getInitialsAvatarAppearance("Compañía A");
for (const name of [" COMPAÑÍA A ", "compania a", "Compañía A"]) {
assert.equal(getInitialsAvatarAppearance(name).background, reference.background);
}
assert.notEqual(reference.background, getInitialsAvatarAppearance("Compañía B").background);
});
test("los colores generados mantienen contraste AA para texto pequeño", () => {
const luminance = (hex: string) => {
const channels = [1, 3, 5]
.map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255)
.map((channel) =>
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4
);
return (channels[0] ?? 0) * 0.2126 + (channels[1] ?? 0) * 0.7152 + (channels[2] ?? 0) * 0.0722;
};
const backgrounds = new Set<string>();
for (let index = 0; index < 100; index++) {
const appearance = getInitialsAvatarAppearance(`Company ${index}`);
backgrounds.add(appearance.background);
assert.ok(
(luminance(appearance.background) + 0.05) / (luminance(appearance.foreground) + 0.05) >= 4.5
);
}
assert.equal(backgrounds.size, 12);
});
test("el avatar conserva su nombre accesible y puede ser decorativo dentro de un botón etiquetado", () => {
const markup = renderToStaticMarkup(<InitialsAvatar name="Compañía A" />);
assert.ok(markup.includes('aria-label="Compañía A"'));
assert.ok(markup.includes('role="img"'));
assert.ok(markup.includes(">CA</span>"));
const decorative = renderToStaticMarkup(<InitialsAvatar aria-hidden="true" name="Compañía A" />);
assert.ok(decorative.includes('aria-hidden="true"'));
});

View File

@ -0,0 +1,28 @@
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentProps } from "react";
import { getInitialsAvatarAppearance } from "./initials-avatar-appearance";
export function InitialsAvatar({
name,
className,
style,
...props
}: Omit<ComponentProps<"span">, "children"> & { name: string }) {
const appearance = getInitialsAvatarAppearance(name);
return (
<span
{...props}
aria-label={name}
className={cn(
"inline-flex size-8 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold leading-none select-none",
className
)}
data-slot="initials-avatar"
role="img"
style={{ backgroundColor: appearance.background, color: appearance.foreground, ...style }}
>
{appearance.initials}
</span>
);
}

View File

@ -5,6 +5,8 @@ import type { ReactNode } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CompanyIdentity } from "./company-identity";
import { InitialsAvatar } from "./initials-avatar";
import type { WorkspaceLocation } from "./workspace-location";
export function WorkspaceHeader({
companies,
@ -14,7 +16,7 @@ export function WorkspaceHeader({
userName,
languageSwitcher,
}: {
companies: readonly { id: string; name: string }[];
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
onNavigate: (location: WorkspaceLocation) => void;
onSelectCompany: (id: string) => void;
accountNavigation: ReactNode;
@ -25,11 +27,15 @@ export function WorkspaceHeader({
const { t: coreTranslation } = useTranslation("core");
const [search, setSearch] = useState("");
return (
<header className="desk-global-header">
<div className="desk-search">
<SearchIcon aria-hidden="true" />
<header className="flex min-h-[72px] items-center gap-3.5 border-b border-shell-border bg-shell-header p-4 text-shell-navy shell:min-h-shell-header shell:gap-7 shell:py-[15px] shell:pl-[17px] shell:pr-[30px]">
<div className="relative min-w-0 flex-1">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute left-3.5 top-[11px] size-[19px] text-shell-search-icon"
/>
<Input
aria-label={coreTranslation("shell.search")}
className="h-shell-search rounded-shell-control border-shell-border bg-white/65 dark:bg-white/65 pl-[43px] text-sm leading-[21px] shell:leading-5 text-shell-navy shadow-none placeholder:text-shell-muted-navy"
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
@ -40,13 +46,17 @@ export function WorkspaceHeader({
value={search}
/>
{search.trim() && (
<section aria-label={coreTranslation("shell.results")} className="desk-search-results">
<section
aria-label={coreTranslation("shell.results")}
className="absolute inset-x-0 top-[calc(100%+8px)] z-30 grid max-h-80 overflow-y-auto rounded-shell-popover border border-shell-border bg-shell-content p-3 shadow-shell-popover"
>
{companies
.filter((company) =>
company.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
)
.map((company) => (
<Button
className="justify-start"
key={company.id}
onClick={() => {
onSelectCompany(company.id);
@ -54,7 +64,11 @@ export function WorkspaceHeader({
}}
variant="ghost"
>
{company.name}
<CompanyIdentity
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
</Button>
))}
{(
@ -71,6 +85,7 @@ export function WorkspaceHeader({
)
.map(([area, label]) => (
<Button
className="justify-start"
key={area}
onClick={() => {
onNavigate({ area });
@ -81,35 +96,39 @@ export function WorkspaceHeader({
{t(label)}
</Button>
))}
<p>{coreTranslation("shell.searchScope")}</p>
<p className="p-2 text-xs leading-[1.5] text-shell-muted-navy">
{coreTranslation("shell.searchScope")}
</p>
</section>
)}
</div>
<span
aria-label={coreTranslation("shell.notificationsUnavailable")}
className="desk-notifications"
className="flex grow-0 shrink-0 basis-6"
role="img"
title={coreTranslation("shell.notificationsUnavailable")}
>
<BellIcon aria-hidden="true" />
<BellIcon aria-hidden="true" className="size-6 stroke-[1.6]" />
</span>
<details className="desk-profile">
<summary aria-label={coreTranslation("shell.profile")}>
<span aria-hidden="true" className="desk-avatar">
{userName
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => word[0])
.join("")}
<details className="relative shrink-0">
<summary
aria-label={coreTranslation("shell.profile")}
className="flex cursor-pointer list-none items-center gap-1.5 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-shell-accent shell:gap-[13px] [&::-webkit-details-marker]:hidden"
>
<InitialsAvatar
aria-hidden="true"
className="size-shell-avatar text-xs"
name={userName}
/>
<span className="hidden max-w-[150px] flex-col leading-[1.25] shell:flex">
<strong className="truncate text-sm font-[650] leading-[1.25]">{userName}</strong>
<span className="text-xs leading-[1.25] text-shell-muted-navy">
{coreTranslation("shell.supportWorkspace")}
</span>
</span>
<span className="desk-profile-copy">
<strong>{userName}</strong>
<span>{coreTranslation("shell.supportWorkspace")}</span>
</span>
<ChevronDownIcon aria-hidden="true" />
<ChevronDownIcon aria-hidden="true" className="ml-0.5 size-4" />
</summary>
<div className="desk-profile-menu">
<div className="absolute right-0 top-[calc(100%+8px)] z-30 w-[min(300px,calc(100vw-32px))] rounded-shell-popover border border-shell-border bg-shell-content p-3 shadow-shell-popover shell:w-[300px]">
{accountNavigation}
{languageSwitcher}
</div>

View File

@ -0,0 +1,23 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { cn } from "@repo/shadcn-ui/lib/utils";
import type { ComponentProps } from "react";
export function WorkspaceNavigationButton({
level = "primary",
className,
...props
}: ComponentProps<typeof Button> & { level?: "primary" | "catalog" }) {
return (
<Button
{...props}
className={cn(
"shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:px-0 shell:group-data-[collapsed=true]/sidebar:[&>span:not([data-slot$=avatar])]:hidden",
level === "catalog"
? "h-auto min-h-[38px] justify-start gap-2.5 whitespace-normal text-left text-shell-sidebar-muted aria-pressed:bg-white/[0.08] aria-pressed:hover:bg-white/[0.08] dark:aria-pressed:hover:bg-white/[0.08] aria-pressed:text-white"
: "h-[42px] min-h-[42px] w-auto justify-start gap-2 border-0 border-l-2 border-l-transparent rounded-shell-control bg-transparent px-2.5 py-0 text-sm font-normal text-shell-sidebar-foreground shadow-none hover:bg-white/[0.06] hover:text-white dark:hover:bg-white/[0.06] aria-expanded:bg-transparent aria-expanded:hover:bg-white/[0.06] dark:aria-expanded:hover:bg-white/[0.06] aria-expanded:text-shell-sidebar-foreground aria-expanded:hover:text-white aria-pressed:border-l-shell-accent aria-pressed:bg-[linear-gradient(90deg,var(--color-shell-active-start),var(--color-shell-active-end))] aria-pressed:text-white aria-pressed:hover:bg-transparent dark:aria-pressed:hover:bg-transparent focus-visible:border-transparent focus-visible:ring-0 focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-3 focus-visible:outline-shell-accent aria-pressed:focus-visible:border-l-shell-accent shell:h-shell-navigation shell:min-h-shell-navigation shell:w-full shell:gap-[19px] shell:pl-3.5 shell:pr-3 shell:text-base shell:leading-[1.4285714286] [&>svg]:stroke-[1.6] shell:group-data-[collapsed=true]/sidebar:gap-0 [&>span:not([data-slot$=avatar])]:truncate",
className
)}
variant="ghost"
/>
);
}

View File

@ -1,4 +1,3 @@
import { Button } from "@repo/shadcn-ui/components/button";
import {
BoxIcon,
Building2Icon,
@ -16,8 +15,10 @@ import type { ComponentProps } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CompanyAvatar } from "./company-avatar";
import type { WorkspaceContent } from "./workspace-content";
import type { WorkspaceLocation } from "./workspace-location";
import { WorkspaceNavigationButton } from "./workspace-navigation-button";
export function WorkspaceSidebar({
companies,
current,
@ -27,7 +28,7 @@ export function WorkspaceSidebar({
onSelectCatalog,
onSettings,
}: {
companies: readonly { id: string; name: string }[];
companies: readonly { id: string; name: string; logoVersion?: string | null | undefined }[];
onNavigate: (location: WorkspaceLocation) => void;
onSelectCompany: (id: string) => void;
current: WorkspaceLocation;
@ -42,9 +43,20 @@ export function WorkspaceSidebar({
const { t: accessTranslation } = useTranslation("access");
const [collapsed, setCollapsed] = useState(false);
return (
<aside className="desk-sidebar" data-collapsed={collapsed}>
<div aria-label="Metis Desk" className="desk-brand" role="img">
<svg aria-hidden="true" viewBox="0 0 36 30">
<aside
className="group/sidebar flex min-h-0 w-full grow-0 shrink-0 basis-auto flex-col overflow-hidden bg-[linear-gradient(180deg,var(--color-shell-sidebar-top),var(--color-shell-sidebar)_45%,var(--color-shell-sidebar-bottom))] text-shell-sidebar-foreground shell:w-shell-sidebar shell:basis-shell-sidebar shell:data-[collapsed=true]:w-shell-sidebar-collapsed shell:data-[collapsed=true]:basis-shell-sidebar-collapsed"
data-collapsed={collapsed}
>
<div
aria-label="Metis Desk"
className="flex shrink-0 items-center gap-[13px] whitespace-nowrap p-0.5 text-[21px] font-[650] leading-[30px] tracking-[-0.5px] shell:p-0 shell:pl-[30px] shell:pt-[31px] shell:group-data-[collapsed=true]/sidebar:justify-center shell:group-data-[collapsed=true]/sidebar:pl-0"
role="img"
>
<svg
aria-hidden="true"
className="h-[30px] w-8 shrink-0 text-shell-accent"
viewBox="0 0 36 30"
>
<path
d="M5 24 13 7l9 17 9-18"
fill="none"
@ -54,9 +66,12 @@ export function WorkspaceSidebar({
strokeWidth="7"
/>
</svg>
<span>Metis Desk</span>
<span className="shell:group-data-[collapsed=true]/sidebar:hidden">Metis Desk</span>
</div>
<nav aria-label={t("companySpaces")} className="desk-company-rail">
<nav
aria-label={t("companySpaces")}
className="flex min-h-0 flex-none flex-row flex-wrap gap-1.5 overflow-y-visible px-4 pb-4 shell:flex-1 shell:flex-col shell:flex-nowrap shell:gap-0.5 shell:overflow-y-auto shell:px-shell-sidebar-inset shell:pb-6 shell:pt-7 shell:group-data-[collapsed=true]/sidebar:px-3 shell:group-data-[collapsed=true]/sidebar:pb-3"
>
{(
[
["home", "dashboard.title", HomeIcon],
@ -66,116 +81,121 @@ export function WorkspaceSidebar({
["interventions", "shell.interventions", WrenchIcon],
] as const
).map(([area, label, Icon]) => (
<Button
<WorkspaceNavigationButton
aria-label={t(label)}
aria-pressed={current.area === area}
className="desk-space-button"
key={area}
onClick={() => onNavigate({ area })}
title={t(label)}
variant="ghost"
>
<Icon aria-hidden="true" />
<Icon aria-hidden="true" className="size-5 shell:size-6" />
<span>{t(label)}</span>
{area === "catalogs" && (
<ChevronRightIcon aria-hidden="true" className="desk-nav-chevron" />
<ChevronRightIcon
aria-hidden="true"
className="ml-auto size-[18px] shell:group-data-[collapsed=true]/sidebar:hidden"
/>
)}
</Button>
</WorkspaceNavigationButton>
))}
{current.area === "catalogs" && (
<nav aria-label={t("globalCatalogs")} className="desk-catalog-navigation">
<nav
aria-label={t("globalCatalogs")}
className="grid basis-full shell:basis-auto shell:pl-[18px] shell:group-data-[collapsed=true]/sidebar:pl-0"
>
<>
<Button
<WorkspaceNavigationButton
aria-label={accessTranslation("catalogTitle")}
aria-pressed={catalog === "access"}
level="catalog"
onClick={() => onSelectCatalog("access")}
title={accessTranslation("catalogTitle")}
variant="ghost"
>
<KeyRoundIcon aria-hidden="true" />
<KeyRoundIcon aria-hidden="true" className="size-[18px]" />
<span>{accessTranslation("catalogTitle")}</span>
</Button>
<Button
</WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={assetTranslation("title")}
aria-pressed={catalog === "assets"}
level="catalog"
onClick={() => onSelectCatalog("assets")}
title={assetTranslation("title")}
variant="ghost"
>
<BoxIcon aria-hidden="true" />
<BoxIcon aria-hidden="true" className="size-[18px]" />
<span>{assetTranslation("title")}</span>
</Button>
<Button
</WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={t("interventionTemplates.title")}
aria-pressed={catalog === "intervention-templates"}
level="catalog"
onClick={() => onSelectCatalog("intervention-templates")}
title={t("interventionTemplates.title")}
variant="ghost"
>
<LayoutTemplateIcon aria-hidden="true" />
<LayoutTemplateIcon aria-hidden="true" className="size-[18px]" />
<span>{t("interventionTemplates.title")}</span>
</Button>
<Button
</WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={assetTranslation("brands.title")}
aria-pressed={catalog === "brands"}
level="catalog"
onClick={() => onSelectCatalog("brands")}
title={assetTranslation("brands.title")}
variant="ghost"
>
<LayersIcon aria-hidden="true" />
<LayersIcon aria-hidden="true" className="size-[18px]" />
<span>{assetTranslation("brands.title")}</span>
</Button>
<Button
</WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-label={resourceTranslation("title")}
aria-pressed={catalog === "resources"}
level="catalog"
onClick={() => onSelectCatalog("resources")}
title={resourceTranslation("title")}
variant="ghost"
>
<DatabaseIcon aria-hidden="true" />
<DatabaseIcon aria-hidden="true" className="size-[18px]" />
<span>{resourceTranslation("title")}</span>
</Button>
</WorkspaceNavigationButton>
</>
</nav>
)}
<div className="desk-rail-separator" />
<div className="my-1.5 h-px shrink-0 basis-full bg-white/[0.12] shell:mb-[25px] shell:mt-[26px] shell:basis-auto" />
{companies.map((company) => (
<Button
<WorkspaceNavigationButton
aria-label={company.name}
aria-pressed={current.area === "company" && current.id === company.id}
className="desk-space-button"
key={company.id}
onClick={() => onSelectCompany(company.id)}
title={company.name}
variant="ghost"
>
<Building2Icon aria-hidden="true" />
<CompanyAvatar
aria-hidden="true"
className="size-6 shell:size-7"
companyId={company.id}
logoVersion={company.logoVersion}
name={company.name}
/>
<span>{company.name}</span>
</Button>
</WorkspaceNavigationButton>
))}
</nav>
<footer className="desk-sidebar-footer">
<Button
<footer className="grid shrink-0 gap-0.5 border-t border-white/[0.12] px-4 py-2.5 shell:px-shell-sidebar-inset shell:pb-5 shell:pt-3 shell:group-data-[collapsed=true]/sidebar:px-3">
<WorkspaceNavigationButton
aria-label={coreTranslation("shell.settings")}
className="desk-space-button"
onClick={onSettings}
title={coreTranslation("shell.settings")}
variant="ghost"
>
<SettingsIcon aria-hidden="true" />
<SettingsIcon aria-hidden="true" className="size-5 shell:size-6" />
<span>{coreTranslation("shell.settings")}</span>
</Button>
<Button
</WorkspaceNavigationButton>
<WorkspaceNavigationButton
aria-expanded={!collapsed}
aria-label={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
className="desk-space-button desk-sidebar-toggle"
className="hidden shell:inline-flex [&[aria-expanded=true]>svg]:rotate-180"
onClick={() => setCollapsed((value) => !value)}
title={coreTranslation(collapsed ? "shell.expandSidebar" : "shell.collapseSidebar")}
variant="ghost"
>
<ChevronRightIcon aria-hidden="true" />
<ChevronRightIcon aria-hidden="true" className="size-5 shell:size-6" />
<span>{coreTranslation("shell.collapseSidebar")}</span>
</Button>
</WorkspaceNavigationButton>
</footer>
</aside>
);

View File

@ -1,8 +1,8 @@
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useTranslation } from "react-i18next";
import type { InterventionListDTO } from "../../common/interventions";
import { CompanySelect } from "../components/company-select";
export function InterventionCompanyField({
prefix,
@ -26,7 +26,7 @@ export function InterventionCompanyField({
{fixed ? (
<p>{companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
) : (
<SelectWithAction
<CompanySelect
action={{ label: t("newCompany"), onClick: onNew }}
id={prefix}
label={t("tickets.company")}

View File

@ -13,6 +13,8 @@ import { useTranslation } from "react-i18next";
import type { InterventionDTO } from "../../common/interventions";
import { InterventionStateSchema } from "../../common/interventions";
import { CompanyIdentity } from "../components/company-identity";
import { CompanySelect } from "../components/company-select";
import { interventionApi, interventionKeys } from "./intervention-api";
import { InterventionDetailDialog } from "./intervention-detail-dialog";
@ -157,7 +159,9 @@ export function InterventionsPage({
id: "company",
label: t("tickets.company"),
value: (intervention) => intervention.companyName,
render: (intervention) => <>{intervention.companyName}</>,
render: (intervention) => (
<CompanyIdentity companyId={intervention.companyId} name={intervention.companyName} />
),
},
{
id: "state",
@ -262,7 +266,7 @@ export function InterventionsPage({
{t("dashboard.pendingFilter")}
</label>{" "}
{!companyId && (
<SelectWithAction
<CompanySelect
id="interventions-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}

View File

@ -18,6 +18,7 @@ import type {
TicketTargetInput,
} from "../../common/tickets";
import { TicketInputSchema } from "../../common/tickets";
import { CompanySelect } from "../components/company-select";
import { ticketApi, ticketKeys } from "./ticket-api";
import { TicketClassificationFields } from "./ticket-classification-fields";
@ -99,7 +100,7 @@ export function TicketForm({
<p>{data.companies.find((item) => item.id === companyId)?.name ?? companyId}</p>
) : (
<>
<SelectWithAction
<CompanySelect
action={{ label: t("newCompany"), onClick: () => setQuick("company") }}
id={prefix}
label={t("tickets.company")}

View File

@ -13,6 +13,8 @@ import { useTranslation } from "react-i18next";
import type { TicketDTO } from "../../common/tickets";
import { TicketPrioritySchema, TicketStateSchema } from "../../common/tickets";
import { CompanyIdentity } from "../components/company-identity";
import { CompanySelect } from "../components/company-select";
import { ticketApi, ticketKeys } from "./ticket-api";
import { TicketDetailDialog } from "./ticket-detail-dialog";
@ -160,7 +162,9 @@ export function TicketsPage({
id: "company",
label: t("tickets.company"),
value: (ticket) => ticket.companyName,
render: (ticket) => <>{ticket.companyName}</>,
render: (ticket) => (
<CompanyIdentity companyId={ticket.companyId} name={ticket.companyName} />
),
},
{
id: "state",
@ -237,7 +241,7 @@ export function TicketsPage({
{t("dashboard.pendingFilter")}
</label>{" "}
{!companyId && (
<SelectWithAction
<CompanySelect
id="tickets-company-filter"
label={t("tickets.company")}
onValueChange={(company) => setFilters({ ...filters, company })}

View File

@ -242,6 +242,9 @@ importers:
sequelize:
specifier: 6.37.8
version: 6.37.8(mariadb@3.5.4)
sharp:
specifier: 0.35.5
version: 0.35.5(@types/node@25.6.0)
zod:
specifier: 4.3.6
version: 4.3.6
@ -723,6 +726,9 @@ packages:
'@dotenvx/primitives@0.8.0':
resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==}
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@ -905,6 +911,168 @@ packages:
peerDependencies:
react-hook-form: ^7.55.0
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.5':
resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.5':
resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.5':
resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.4':
resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.4':
resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.4':
resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.4':
resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.4':
resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.4':
resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.4':
resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.4':
resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.5':
resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.5':
resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.5':
resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.5':
resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.5':
resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.5':
resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.5':
resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.5':
resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.5':
resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.5':
resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.5':
resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.5':
resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.5':
resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@ -2839,6 +3007,15 @@ packages:
engines: {node: '>=20.18.1'}
hasBin: true
sharp@0.35.5:
resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -3463,6 +3640,11 @@ snapshots:
'@dotenvx/primitives@0.8.0': {}
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.7':
optional: true
@ -3567,6 +3749,112 @@ snapshots:
'@standard-schema/utils': 0.3.0
react-hook-form: 7.72.1(react@19.2.5)
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.4
optional: true
'@img/sharp-darwin-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.4
optional: true
'@img/sharp-freebsd-wasm32@0.35.5':
dependencies:
'@img/sharp-wasm32': 0.35.5
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.4':
optional: true
'@img/sharp-libvips-linux-arm@1.3.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.4':
optional: true
'@img/sharp-libvips-linux-x64@1.3.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.4':
optional: true
'@img/sharp-linux-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.4
optional: true
'@img/sharp-linux-arm@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.4
optional: true
'@img/sharp-linux-ppc64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.4
optional: true
'@img/sharp-linux-riscv64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.4
optional: true
'@img/sharp-linux-s390x@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.4
optional: true
'@img/sharp-linux-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.4
optional: true
'@img/sharp-linuxmusl-arm64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
optional: true
'@img/sharp-linuxmusl-x64@0.35.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
optional: true
'@img/sharp-wasm32@0.35.5':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.5':
dependencies:
'@img/sharp-wasm32': 0.35.5
optional: true
'@img/sharp-win32-arm64@0.35.5':
optional: true
'@img/sharp-win32-ia32@0.35.5':
optional: true
'@img/sharp-win32-x64@0.35.5':
optional: true
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
@ -5353,6 +5641,39 @@ snapshots:
- supports-color
- typescript
sharp@0.35.5(@types/node@25.6.0):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.5
'@img/sharp-darwin-x64': 0.35.5
'@img/sharp-freebsd-wasm32': 0.35.5
'@img/sharp-libvips-darwin-arm64': 1.3.4
'@img/sharp-libvips-darwin-x64': 1.3.4
'@img/sharp-libvips-linux-arm': 1.3.4
'@img/sharp-libvips-linux-arm64': 1.3.4
'@img/sharp-libvips-linux-ppc64': 1.3.4
'@img/sharp-libvips-linux-riscv64': 1.3.4
'@img/sharp-libvips-linux-s390x': 1.3.4
'@img/sharp-libvips-linux-x64': 1.3.4
'@img/sharp-libvips-linuxmusl-arm64': 1.3.4
'@img/sharp-libvips-linuxmusl-x64': 1.3.4
'@img/sharp-linux-arm': 0.35.5
'@img/sharp-linux-arm64': 0.35.5
'@img/sharp-linux-ppc64': 0.35.5
'@img/sharp-linux-riscv64': 0.35.5
'@img/sharp-linux-s390x': 0.35.5
'@img/sharp-linux-x64': 0.35.5
'@img/sharp-linuxmusl-arm64': 0.35.5
'@img/sharp-linuxmusl-x64': 0.35.5
'@img/sharp-webcontainers-wasm32': 0.35.5
'@img/sharp-win32-arm64': 0.35.5
'@img/sharp-win32-ia32': 0.35.5
'@img/sharp-win32-x64': 0.35.5
'@types/node': 25.6.0
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0