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:
parent
1f21a605cc
commit
5515191eff
433
TODO.md
433
TODO.md
@ -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.
|
||||
131
apps/server/src/company-logos.integration.ts
Normal file
131
apps/server/src/company-logos.integration.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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%);
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}}
|
||||
|
||||
@ -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.
|
||||
|
||||
20
docs/logotipos-de-companias.md
Normal file
20
docs/logotipos-de-companias.md
Normal 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.
|
||||
@ -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">
|
||||
|
||||
@ -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"
|
||||
: "";
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
11
modules/companies/src/api/domain/company-logo-error.ts
Normal file
11
modules/companies/src/api/domain/company-logo-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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)) {
|
||||
|
||||
@ -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 }
|
||||
);
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
14
modules/companies/src/common/company-logo.ts
Normal file
14
modules/companies/src/common/company-logo.ts
Normal 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(),
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
29
modules/companies/src/web/api/company-logo-api.ts
Normal file
29
modules/companies/src/web/api/company-logo-api.ts
Normal 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)}`;
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -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>
|
||||
|
||||
47
modules/companies/src/web/components/company-avatar.test.tsx
Normal file
47
modules/companies/src/web/components/company-avatar.test.tsx
Normal 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();
|
||||
});
|
||||
62
modules/companies/src/web/components/company-avatar.tsx
Normal file
62
modules/companies/src/web/components/company-avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
21
modules/companies/src/web/components/company-identity.tsx
Normal file
21
modules/companies/src/web/components/company-identity.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
modules/companies/src/web/components/company-logo-field.tsx
Normal file
110
modules/companies/src/web/components/company-logo-field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
modules/companies/src/web/components/company-select.tsx
Normal file
87
modules/companies/src/web/components/company-select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 />}
|
||||
|
||||
11
modules/companies/src/web/components/dashboard-table.tsx
Normal file
11
modules/companies/src/web/components/dashboard-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
};
|
||||
}
|
||||
@ -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"'));
|
||||
});
|
||||
28
modules/companies/src/web/components/initials-avatar.tsx
Normal file
28
modules/companies/src/web/components/initials-avatar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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 })}
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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 })}
|
||||
|
||||
321
pnpm-lock.yaml
321
pnpm-lock.yaml
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user