From 5515191eff6d3da342594eae543e97ce3350ee0a Mon Sep 17 00:00:00 2001 From: David Date: Thu, 8 Oct 2026 18:31:46 +0200 Subject: [PATCH] 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. --- TODO.md | 433 +++++++++- apps/server/src/company-logos.integration.ts | 131 +++ apps/server/src/identity.test.ts | 8 + apps/web/index.html | 2 +- apps/web/src/app.test.tsx | 52 ++ apps/web/src/app.tsx | 6 +- apps/web/src/global.css | 751 +----------------- apps/web/src/language-switcher.tsx | 7 +- docs/componentes-de-interfaz.md | 51 +- docs/logotipos-de-companias.md | 20 + .../assets/src/web/asset-selection-panel.tsx | 5 +- modules/assets/src/web/inventory-page.tsx | 18 +- modules/companies/package.json | 23 +- .../api/application/company-logo.use-cases.ts | 58 ++ .../application/company-response.builder.ts | 7 +- .../src/api/domain/company-logo-error.ts | 11 + .../src/api/domain/company.aggregate.ts | 4 + .../company-logo-processor.test.ts | 73 ++ .../infrastructure/company-logo-processor.ts | 86 ++ .../http/company-logo.routes.ts | 86 ++ .../persistence/company-logo-migration.ts | 26 + .../persistence/company-persistence.mapper.ts | 7 +- .../persistence/company.model.ts | 9 +- .../sequelize-company-logo.repository.ts | 55 ++ modules/companies/src/api/module.ts | 11 + modules/companies/src/common/company-logo.ts | 14 + .../dto/response/company.response.dto.ts | 3 + .../src/common/dto/shared/company.dto.ts | 7 +- modules/companies/src/common/index.ts | 6 + modules/companies/src/common/locales/en.json | 15 +- modules/companies/src/common/locales/es.json | 15 +- .../companies/src/web/api/company-logo-api.ts | 29 + .../src/web/components/companies-page.tsx | 7 +- .../web/components/companies-workspace.tsx | 31 +- .../web/components/company-avatar.test.tsx | 47 ++ .../src/web/components/company-avatar.tsx | 62 ++ .../src/web/components/company-detail.tsx | 54 +- .../src/web/components/company-form.tsx | 39 +- .../src/web/components/company-identity.tsx | 21 + .../src/web/components/company-logo-field.tsx | 110 +++ .../src/web/components/company-select.tsx | 87 ++ .../components/dashboard-activity-panel.tsx | 21 +- .../components/dashboard-attention-panel.tsx | 8 +- .../components/dashboard-companies-panel.tsx | 174 ++-- .../components/dashboard-details-dialog.tsx | 28 +- .../dashboard-navigation-button.tsx | 18 + .../src/web/components/dashboard-page.tsx | 6 +- .../src/web/components/dashboard-panel.tsx | 7 +- .../web/components/dashboard-state-badge.tsx | 25 + .../web/components/dashboard-statistics.tsx | 6 +- .../src/web/components/dashboard-table.tsx | 11 + .../web/components/dashboard-work-panel.tsx | 121 ++- .../components/initials-avatar-appearance.ts | 42 + .../web/components/initials-avatar.test.tsx | 56 ++ .../src/web/components/initials-avatar.tsx | 28 + .../src/web/components/workspace-header.tsx | 67 +- .../workspace-navigation-button.tsx | 23 + .../src/web/components/workspace-sidebar.tsx | 122 +-- .../intervention-company-field.tsx | 4 +- .../web/interventions/interventions-page.tsx | 8 +- .../companies/src/web/tickets/ticket-form.tsx | 3 +- .../src/web/tickets/tickets-page.tsx | 8 +- pnpm-lock.yaml | 321 ++++++++ 63 files changed, 2546 insertions(+), 1048 deletions(-) create mode 100644 apps/server/src/company-logos.integration.ts create mode 100644 docs/logotipos-de-companias.md create mode 100644 modules/companies/src/api/application/company-logo.use-cases.ts create mode 100644 modules/companies/src/api/domain/company-logo-error.ts create mode 100644 modules/companies/src/api/infrastructure/company-logo-processor.test.ts create mode 100644 modules/companies/src/api/infrastructure/company-logo-processor.ts create mode 100644 modules/companies/src/api/infrastructure/http/company-logo.routes.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/company-logo-migration.ts create mode 100644 modules/companies/src/api/infrastructure/persistence/sequelize-company-logo.repository.ts create mode 100644 modules/companies/src/common/company-logo.ts create mode 100644 modules/companies/src/web/api/company-logo-api.ts create mode 100644 modules/companies/src/web/components/company-avatar.test.tsx create mode 100644 modules/companies/src/web/components/company-avatar.tsx create mode 100644 modules/companies/src/web/components/company-identity.tsx create mode 100644 modules/companies/src/web/components/company-logo-field.tsx create mode 100644 modules/companies/src/web/components/company-select.tsx create mode 100644 modules/companies/src/web/components/dashboard-navigation-button.tsx create mode 100644 modules/companies/src/web/components/dashboard-state-badge.tsx create mode 100644 modules/companies/src/web/components/dashboard-table.tsx create mode 100644 modules/companies/src/web/components/initials-avatar-appearance.ts create mode 100644 modules/companies/src/web/components/initials-avatar.test.tsx create mode 100644 modules/companies/src/web/components/initials-avatar.tsx create mode 100644 modules/companies/src/web/components/workspace-navigation-button.tsx diff --git a/TODO.md b/TODO.md index 3a6e77e..7677a2e 100644 --- a/TODO.md +++ b/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. \ No newline at end of file +- 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//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. \ No newline at end of file diff --git a/apps/server/src/company-logos.integration.ts b/apps/server/src/company-logos.integration.ts new file mode 100644 index 0000000..8afbc90 --- /dev/null +++ b/apps/server/src/company-logos.integration.ts @@ -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); +} diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index 265a502..d078a03 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -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); diff --git a/apps/web/index.html b/apps/web/index.html index 5807410..2486ef0 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,7 +6,7 @@ Metis Desk - +
diff --git a/apps/web/src/app.test.tsx b/apps/web/src/app.test.tsx index 140d56a..78f5d37 100644 --- a/apps/web/src/app.test.tsx +++ b/apps/web/src/app.test.tsx @@ -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( + + 2 + + ); + 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")); +}); + +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( + {label} + ); + assert.ok(markup.includes(`>${label}`)); + 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( + + + + Reference + + + + + T-2026-001 + + + + ); + assert.match(markup, /]*>Pending work<\/caption>/); + assert.ok(markup.includes('Reference')); + assert.ok(markup.includes("T-2026-001")); +}); + async function renderPage( language: string, session?: SessionResponseDTO, diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 84d40b3..5798f78 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -11,11 +11,11 @@ export function App() { retry: false, }); return ( -
+
{!session.data?.user && ( -
+
Metis Desk - +
)} 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%); } diff --git a/apps/web/src/language-switcher.tsx b/apps/web/src/language-switcher.tsx index 9c6cb79..8a8aee2 100644 --- a/apps/web/src/language-switcher.tsx +++ b/apps/web/src/language-switcher.tsx @@ -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 ( -
+
{t("language")} { + 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" + /> +

+ {t("logo.help")} +

+ {error && ( + + )} + {draft !== null && (logoVersion || draft) && ( + + )} + {draft !== undefined && ( + + )} +
+ ); +} diff --git a/modules/companies/src/web/components/company-select.tsx b/modules/companies/src/web/components/company-select.tsx new file mode 100644 index 0000000..a92260b --- /dev/null +++ b/modules/companies/src/web/components/company-select.tsx @@ -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) { + const selected = options.find((option) => option.value === value); + const select = ( + + ); + if (!action) { + return select; + } + return ( + + {select} + + + ); +} diff --git a/modules/companies/src/web/components/dashboard-activity-panel.tsx b/modules/companies/src/web/components/dashboard-activity-panel.tsx index f519b3b..ebc6a2c 100644 --- a/modules/companies/src/web/components/dashboard-activity-panel.tsx +++ b/modules/companies/src/web/components/dashboard-activity-panel.tsx @@ -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} > -
    +
      {activity.data?.items.slice(0, 4).map((item) => { const Icon = KIND_ICONS[item.kind]; return ( -
    1. +
    2. diff --git a/modules/companies/src/web/components/dashboard-companies-panel.tsx b/modules/companies/src/web/components/dashboard-companies-panel.tsx index 42b080c..1ad30f3 100644 --- a/modules/companies/src/web/components/dashboard-companies-panel.tsx +++ b/modules/companies/src/web/components/dashboard-companies-panel.tsx @@ -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")} > -
      - - - - - {[ - t("tickets.company"), - t("dashboard.openTickets"), - t("dashboard.pendingInterventions"), - t("dashboard.registeredAssets"), - t("people"), - ].map((label) => ( - - ))} - - - - {totals.data?.items.slice(0, 5).map((item) => ( - - - - - - - + + + + {[ + t("tickets.company"), + t("dashboard.openTickets"), + t("dashboard.pendingInterventions"), + t("dashboard.registeredAssets"), + t("people"), + ].map((label) => ( + ))} - -
      {t("dashboard.companySummary")}
      - {label} -
      - - - - - - - - - -
      + {label} +
      -
      + + + + {totals.data?.items.slice(0, 5).map((item) => ( + + + + onNavigate({ area: "company", id: item.id, section: "inventory" }) + } + > + + + + + + onNavigate({ area: "tickets", companyId: item.id, pendingOnly: true }) + } + > + {item.tickets} + + + + + onNavigate({ + area: "interventions", + companyId: item.id, + pendingOnly: true, + }) + } + > + {item.interventions} + + + + + onNavigate({ area: "company", id: item.id, section: "inventory" }) + } + > + + {t("dashboard.registeredAssets")} · {item.name}:{" "} + + {item.assets} + + + + onNavigate({ area: "company", id: item.id, section: "people" })} + > + + {t("people")} · {item.name}:{" "} + + {item.people} + + + + ))} + +
      ); diff --git a/modules/companies/src/web/components/dashboard-details-dialog.tsx b/modules/companies/src/web/components/dashboard-details-dialog.tsx index ccb905b..824a8a9 100644 --- a/modules/companies/src/web/components/dashboard-details-dialog.tsx +++ b/modules/companies/src/web/components/dashboard-details-dialog.tsx @@ -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) => ( - + + ), }, { @@ -139,9 +140,8 @@ export function DashboardDetailsDialog({ query={query} >

      {range}

      -
      - - +
      +
      @@ -155,16 +155,16 @@ export function DashboardDetailsDialog({ {query.data?.items.map((item) => ( + - {details.activity && ( ))} -
      {title}
      {t("name")}
      - + + + {item.companyName} {item.recordedAt @@ -187,7 +187,7 @@ export function DashboardDetailsDialog({
      +
      {!details.activity && query.isSuccess && ( diff --git a/modules/companies/src/web/components/dashboard-navigation-button.tsx b/modules/companies/src/web/components/dashboard-navigation-button.tsx new file mode 100644 index 0000000..53cc6d5 --- /dev/null +++ b/modules/companies/src/web/components/dashboard-navigation-button.tsx @@ -0,0 +1,18 @@ +import { cn } from "@repo/shadcn-ui/lib/utils"; +import type { ComponentProps } from "react"; + +export function DashboardNavigationButton({ + className, + ...props +}: Omit, "type">) { + return ( + ))} {( @@ -71,6 +85,7 @@ export function WorkspaceHeader({ ) .map(([area, label]) => ( ))} -

      {coreTranslation("shell.searchScope")}

      +

      + {coreTranslation("shell.searchScope")} +

      )}
- -
- -