From ece38c741e092fc2bf6348975d1b9395cc8b12a1 Mon Sep 17 00:00:00 2001 From: David Date: Thu, 15 Oct 2026 12:32:37 +0200 Subject: [PATCH] feat: enhance resource management UI with template icons and selection panel - Added ResourceTemplateIcon component to display icons for resource templates in CatalogDefinitionDialog and CatalogName. - Updated CatalogDefinitionDialog to conditionally render template icons in the title. - Enhanced ResourcesPage to include ResourceSelectionPanel for detailed resource views and actions. - Implemented compact view options in ResourceDetail for a cleaner interface. - Introduced new resource template icons and updated related tests to ensure proper functionality. - Added PersonPhotoField component for managing user photos with preview functionality. - Improved characteristics summary display in resources list. --- apps/server/src/resources.integration.ts | 23 +- .../companies/src/common/locales/en.json | 3 +- .../companies/src/common/locales/es.json | 3 +- docs/componentes-de-interfaz.md | 37 ++- docs/patron-de-listados-de-entidades.md | 2 +- docs/recursos-campos-y-plantillas.md | 19 ++ .../detail/ui/asset-selection-panel.tsx | 36 ++- .../use-inventory-page-controller.ts | 4 +- .../src/web/assets/shared/inventory.test.tsx | 6 +- modules/companies/src/common/locales/en.json | 10 +- modules/companies/src/common/locales/es.json | 10 +- .../people/shared/ui/person-editor-dialog.tsx | 8 +- .../src/web/people/shared/ui/person-form.tsx | 101 ++++--- .../people/shared/ui/person-photo-field.tsx | 82 +++++ .../src/api/domain/resource-catalog-seed.ts | 9 +- .../src/api/domain/resource-catalog.test.ts | 95 ++++++ .../src/api/domain/resource-definition.ts | 3 + .../resource-definition-values.vo.ts | 1 + .../resource-catalog-relational.ts | 3 + .../persistence/resource-catalog.model.ts | 16 + .../persistence/resource-relational-schema.ts | 1 + modules/resources/src/common/locales/en.json | 19 ++ modules/resources/src/common/locales/es.json | 19 ++ .../src/common/resource-template-icons.ts | 26 ++ .../use-catalog-template-form-controller.ts | 1 + .../shared/ui/catalog-template-form.tsx | 28 ++ .../shared/ui/resource-template-icon.tsx | 40 +++ .../detail/ui/catalog-definition-dialog.tsx | 8 +- .../src/web/catalog/shared/catalog.test.tsx | 43 +++ .../web/catalog/shared/ui/catalog-name.tsx | 4 +- .../src/web/resources/detail/index.ts | 2 +- .../src/web/resources/detail/ui/index.ts | 1 + .../resources/detail/ui/resource-detail.tsx | 108 ++++--- .../detail/ui/resource-selection-panel.tsx | 71 +++++ .../use-resources-page-controller.ts | 52 +++- .../web/resources/list/ui/resources-page.tsx | 284 +++++++++++------- .../web/resources/shared/resources.test.tsx | 202 ++++++++++++- .../rdx-ui/src/components/selection-panel.tsx | 6 +- .../rdx-ui/src/lib/characteristics-summary.ts | 3 +- packages/rdx-ui/src/lib/icons.ts | 1 + 40 files changed, 1145 insertions(+), 245 deletions(-) create mode 100644 modules/companies/src/web/people/shared/ui/person-photo-field.tsx create mode 100644 modules/resources/src/common/resource-template-icons.ts create mode 100644 modules/resources/src/web/catalog-templates/shared/ui/resource-template-icon.tsx create mode 100644 modules/resources/src/web/resources/detail/ui/resource-selection-panel.tsx diff --git a/apps/server/src/resources.integration.ts b/apps/server/src/resources.integration.ts index 82e69a5..77fcf25 100644 --- a/apps/server/src/resources.integration.ts +++ b/apps/server/src/resources.integration.ts @@ -72,6 +72,24 @@ export async function verifyResources( assert.ok(licenseTemplate && licenseTemplate.values.kind === "template"); + assert.equal(licenseTemplate.values.icon, "file-key"); + const changedIcon = await save(`${global}/${licenseTemplate.id}/update`, { + ...licenseTemplate.values, + icon: "box", + }); + const withIcon = changedIcon.items.find((item) => item.id === licenseTemplate.id); + + assert.ok(withIcon?.values.kind === "template"); + assert.equal(withIcon.values.icon, "box"); + assert.deepEqual(withIcon.bindings, licenseTemplate.bindings); + const iconRows = await database.query<{ icon: string | null }>( + "SELECT icon FROM resource_definitions WHERE id=:id", + { replacements: { id: licenseTemplate.id }, type: QueryTypes.SELECT } + ); + + assert.equal(iconRows[0]?.icon, "box"); + await save(`${global}/${licenseTemplate.id}/update`, licenseTemplate.values); + const licenseFieldId = (key: string) => { const field = initial.items.find((item) => item.builtinKey === `field:${key}`); @@ -172,7 +190,10 @@ export async function verifyResources( const stockTemplate = initial.items.find((item) => item.values.name === "Buzón de correo"); assert.ok(stock && stockTemplate && stockTemplate.values.kind === "template"); - const editedValues = { ...stockTemplate.values, description: "Plantilla global editada" }; + const { icon: previousIcon, ...templateWithoutIcon } = stockTemplate.values; + + assert.equal(previousIcon, "mail"); + const editedValues = { ...templateWithoutIcon, description: "Plantilla global editada" }; const edited = await save(`${global}/${stockTemplate.id}/update`, editedValues); assert.equal(edited.savedId, stockTemplate.id); diff --git a/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/en.json b/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/en.json index 1b31a90..e0741d0 100644 --- a/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/en.json +++ b/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/en.json @@ -815,7 +815,6 @@ "activePerson": "Active person", "inactivePerson": "Inactive person", "isContact": "Company contact", - "personHelp": "This person cannot access Metis Desk. Their IT accounts will be managed separately.", "deactivationHelp": "Assignments and history will be retained. Review any open memberships.", "inactiveMembershipWarning": "This person is inactive and has department assignments that need review.", "contactsOnly": "Contacts only", @@ -1212,4 +1211,4 @@ "INTERNAL_ERROR": "The action could not be completed." } } -} +} \ No newline at end of file diff --git a/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/es.json b/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/es.json index a342dd3..dc266f6 100644 --- a/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/es.json +++ b/archive/tickets-and-interventions/integration/modules/companies/src/common/locales/es.json @@ -815,7 +815,6 @@ "activePerson": "Persona activa", "inactivePerson": "Persona inactiva", "isContact": "Contacto de la compañía", - "personHelp": "Esta persona no tiene acceso a Metis Desk. Sus cuentas informáticas se gestionarán por separado.", "deactivationHelp": "Se conservarán sus asignaciones e historial. Revisa las pertenencias que continúen abiertas.", "inactiveMembershipWarning": "Esta persona está inactiva y conserva asignaciones a departamentos pendientes de revisar.", "contactsOnly": "Solo contactos", @@ -1212,4 +1211,4 @@ "INTERNAL_ERROR": "No se ha podido completar la operación." } } -} +} \ No newline at end of file diff --git a/docs/componentes-de-interfaz.md b/docs/componentes-de-interfaz.md index 3e5d5b6..7b90f04 100644 --- a/docs/componentes-de-interfaz.md +++ b/docs/componentes-de-interfaz.md @@ -343,7 +343,7 @@ Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de ## IconPicker -`@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. El catálogo de plantillas de activos aporta los iconos Lucide y valida sus claves; el componente admite teclado y distribución responsive. +`@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. Los catálogos de plantillas de activos y recursos aportan sus iconos Lucide y validan sus claves; el componente admite teclado y distribución responsive. ## Consulta compacta de plantillas @@ -447,13 +447,46 @@ los menús y demás controles conservan su acción propia. Una URL de activo con Bajo el nombre, inventario y selector muestran un resumen automático: los cuatro primeros campos con valor de la especificación capturada del activo, en el orden de su plantilla. -Incluye etiqueta y unidad, conserva cero y valores booleanos traducidos y omite vacíos y texto +Incluye los valores y sus unidades, conserva cero y valores booleanos traducidos y omite vacíos y texto largo. Cada tipo utiliza así sus propios campos, incluidos los personalizados, sin reglas basadas en nombres ni cambios de persistencia. Si no hay características, se muestra solo el nombre. El resumen ocupa como máximo dos líneas y su texto completo está disponible en `title`. El selector se aplica a activos del puesto (adición múltiple), ordenador principal (único entre miembros), extremo de relaciones, impresora por perfil, origen/destino de conexiones remotas y activos afectados de tickets (adición múltiple). Cada caso conserva exclusiones, referencias fijas y altas rápidas. Añadir varios activos a un puesto es una sola actualización de composición; solo se propone automáticamente el ordenador cuando la composición resultante contiene un único activo. +## Panel lateral y resumen de recursos + +Recursos aplica el mismo patrón de selección que activos: el listado comienza sin ficha, +el nombre y las zonas sin controles de cada fila abren el recurso y conservan la pestaña +y la búsqueda de la URL. La selección se resalta mientras el panel está abierto. El cierre +oculta el panel y ofrece recuperarlo desde la cabecera. Las URLs de un recurso concreto +siguen abriendo su ficha; en pantallas pequeñas se conserva el diálogo con historial. + +`SelectionPanel` de `@repo/rdx-ui/components/selection-panel` comparte la estructura de +activos y recursos: título, cierre accesible, resumen, acciones y contenido por composición. +El consumidor aporta textos traducidos, datos y callbacks. `useCompactDetail` comparte el +umbral de 1100 px con la variante `inventory` de Tailwind. Las fichas completas conservan +su diálogo; el panel de recursos muestra características, integración, notas y contenido +relacionado, sin repetir el historial completo. + +`characteristicsSummary` de `@repo/rdx-ui/lib/characteristics-summary` recibe campos, +valores y textos de Sí/No sin depender de DTOs. Activos y recursos muestran los primeros +cuatro valores no vacíos en el orden de su especificación, con unidades, cero y booleanos +traducidos. El resumen ocupa dos líneas bajo el nombre y ofrece el texto completo en +`title`. No se añaden reglas basadas en nombres de plantillas ni cambios de persistencia. + ## Componentes archivados Las referencias históricas a formularios y listados de tickets, intervenciones y sus plantillas pertenecen al [archivo de funcionalidades](funcionalidades-archivadas.md). No forman parte de la aplicación activa. + +## Formulario de personas + +El alta y la edición comparten `PersonForm` y `PersonEditorDialog`. Los switches de +persona activa y contacto de la compañía aparecen al comienzo, en una franja adaptable. +Los datos personales y de contacto se agrupan con `FormSection variant="plain"` y +rejillas de una o dos columnas. La ayuda contextual permanece al final del cuerpo. + +`PersonPhotoField` encapsula el selector accesible de foto y su vista previa circular, +utilizando Avatar y Button de shadcn/ui. Sin imagen se muestra un placeholder de cámara; +al editar se presenta la foto vigente. La selección es provisional hasta guardar y las +URLs de vista previa se liberan al cambiar de archivo o desmontar el componente. diff --git a/docs/patron-de-listados-de-entidades.md b/docs/patron-de-listados-de-entidades.md index df2bdc2..2103f24 100644 --- a/docs/patron-de-listados-de-entidades.md +++ b/docs/patron-de-listados-de-entidades.md @@ -62,7 +62,7 @@ La aplicación navega por espacios y secciones mediante rutas URL independientes | Usuarios internos | [UserManagement](../modules/identity/src/web/users/list/ui/user-management.tsx) | Sí | | Activos | [AssetInventoryPage](../modules/assets/src/web/assets/list/ui/inventory-page.tsx) | Sí; conserva panel lateral | | Puestos de trabajo | [WorkstationsPage](../modules/assets/src/web/workstations/list/ui/workstations-page.tsx) | Sí | -| Recursos, incluidas aplicaciones y licencias | [ResourcesPage](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | Sí; pestañas por plantilla y «Todo» | +| Recursos, incluidas aplicaciones y licencias | [ResourcesPage](../modules/resources/src/web/resources/list/ui/resources-page.tsx) | Sí; pestañas por plantilla y «Todo», panel lateral y ficha modal móvil | | Campos y plantillas de recursos, global/compañía | [ResourceCatalogPage](../modules/resources/src/web/catalog/list/ui/catalog-page.tsx) | Sí; dos secciones | | Campos, plantillas y relaciones de activos, global/compañía | [AssetCatalogPage](../modules/assets/src/web/catalog/list/ui/catalog-page.tsx) | Sí; tres secciones | | Cuentas y accesos / catálogos informáticos | [AccessWorkspacePage](../modules/companies/src/web/access/list/ui/access-workspace.tsx) | Sí; cuenta, titularidad, grupo, pertenencia, autorización, necesidad, cobertura, entorno, sistema, tipo, permiso y plantilla | diff --git a/docs/recursos-campos-y-plantillas.md b/docs/recursos-campos-y-plantillas.md index 43245c2..6c5e9c3 100644 --- a/docs/recursos-campos-y-plantillas.md +++ b/docs/recursos-campos-y-plantillas.md @@ -118,3 +118,22 @@ características ni las notas. La edición y las altas especializadas integradas en otros formularios mantienen su presentación completa. No cambia el contrato HTTP, la persistencia ni la validación de dominio. + + +## Iconos de plantillas + +Las ocho plantillas de serie incluyen un icono Lucide: correo para buzones, llave para +accesos, carpeta para carpetas compartidas, auriculares para asistencia, monitor para +sistemas operativos, capas para aplicaciones, escudo para certificados y archivo con +llave para licencias. Las plantillas globales y propias de compañía permiten elegir, +cambiar o retirar un icono mediante el selector visual común; las copias conservan la selección. + +El catálogo, su consulta de detalle, la columna Plantilla del listado de recursos y la +cabecera del panel lateral muestran el icono vigente de la plantilla. No forma parte de +la especificación ni modifica el historial del recurso. + +La clave se valida con un conjunto cerrado y se guarda en `resource_definitions.icon`, +columna nullable, sin JSON ni imágenes. Al añadirla a una instalación existente se +asignan una sola vez los iconos de serie por `stockKey`. Los siguientes arranques no +reponen iconos retirados ni cambian las elecciones del usuario. Las plantillas nuevas +reciben los valores de serie desde su carga inicial; los campos no admiten iconos. diff --git a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx index d36da3c..139a9a2 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx @@ -44,22 +44,32 @@ export function AssetSelectionPanel({ return ( + + + + + } closeLabel={t("closeAssetPanel")} + label={t("assetPanel")} onClose={onClose} - title={<> - - {asset.values.name} - } summary={} - actions={<> - - - - } + title={ + <> + + {asset.values.name} + + } > diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts index 4be9d4f..3150772 100644 --- a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -1,10 +1,10 @@ -import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail"; import { navigateTo, useBrowserSearchParams, useEntityNavigation, useListQuery, } from "@metis/core/client"; +import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; @@ -14,8 +14,6 @@ import { removeAsset } from "../../shared/api"; import { getAssetInventory } from "../api"; import type { AssetInventoryPageProps } from "../types"; - - export function useAssetInventoryPageController({ companyId, initialId, diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx index 48f4f5b..85b0dff 100644 --- a/modules/assets/src/web/assets/shared/inventory.test.tsx +++ b/modules/assets/src/web/assets/shared/inventory.test.tsx @@ -559,11 +559,11 @@ test("el resumen usa los campos propios de la plantilla, omite vacíos y texto l assert.equal( assetCharacteristicsSummary(asset, { yes: "Sí", no: "No" }), - "Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Sí" + "ThinkCentre · 0 GB · No · Sí" ); assert.equal( assetCharacteristicsSummary(asset, { yes: "Yes", no: "No" }), - "Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Yes" + "ThinkCentre · 0 GB · No · Yes" ); const html = await render( "es", @@ -573,7 +573,7 @@ test("el resumen usa los campos propios de la plantilla, omite vacíos y texto l { assets: [asset], catalog: { items: [] } } ); - assert.ok(html.includes("Modelo: ThinkCentre · Memoria: 0 GB · Dúplex: No · Color: Sí")); + assert.ok(html.includes("ThinkCentre · 0 GB · No · Sí")); assert.equal(html.includes("Texto que no debe mostrarse"), false); assert.equal( assetCharacteristicsSummary( diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 2956809..4232608 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -599,7 +599,6 @@ "activePerson": "Active person", "inactivePerson": "Inactive person", "isContact": "Company contact", - "personHelp": "This person cannot access Metis Desk. Their IT accounts will be managed separately.", "deactivationHelp": "Assignments and history will be retained. Review any open memberships.", "inactiveMembershipWarning": "This person is inactive and has department assignments that need review.", "contactsOnly": "Contacts only", @@ -715,6 +714,13 @@ "deleteAssignmentConfirm": "Delete the assignment to {{name}}?", "deleteAssignmentError": "The assignment could not be deleted.", "endDateColumn": "End date", + "personForm": { + "identity": "Personal details", + "contact": "Contact details", + "choosePhoto": "Add photo", + "changePhoto": "Change photo", + "photoHelp": "PNG, JPEG or WebP. The photo is applied when you save." + }, "photoOptional": "Photo (optional)", "deleteRecord": { "title": "Delete record", @@ -995,4 +1001,4 @@ "INTERNAL_ERROR": "The action could not be completed." } } -} +} \ No newline at end of file diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index 4b906c0..d117969 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -599,7 +599,6 @@ "activePerson": "Persona activa", "inactivePerson": "Persona inactiva", "isContact": "Contacto de la compañía", - "personHelp": "Esta persona no tiene acceso a Metis Desk. Sus cuentas informáticas se gestionarán por separado.", "deactivationHelp": "Se conservarán sus asignaciones e historial. Revisa las pertenencias que continúen abiertas.", "inactiveMembershipWarning": "Esta persona está inactiva y conserva asignaciones a departamentos pendientes de revisar.", "contactsOnly": "Solo contactos", @@ -715,6 +714,13 @@ "deleteAssignmentConfirm": "¿Eliminar la asignación al departamento {{name}}?", "deleteAssignmentError": "No se puede eliminar la asignación.", "endDateColumn": "Fecha de fin", + "personForm": { + "identity": "Datos personales", + "contact": "Datos de contacto", + "choosePhoto": "Añadir foto", + "changePhoto": "Cambiar foto", + "photoHelp": "PNG, JPEG o WebP. La foto se aplica al guardar." + }, "photoOptional": "Foto (opcional)", "deleteRecord": { "title": "Eliminar registro", @@ -995,4 +1001,4 @@ "INTERNAL_ERROR": "No se ha podido completar la operación." } } -} +} \ No newline at end of file diff --git a/modules/companies/src/web/people/shared/ui/person-editor-dialog.tsx b/modules/companies/src/web/people/shared/ui/person-editor-dialog.tsx index 8c481ad..6db6b05 100644 --- a/modules/companies/src/web/people/shared/ui/person-editor-dialog.tsx +++ b/modules/companies/src/web/people/shared/ui/person-editor-dialog.tsx @@ -1,4 +1,5 @@ import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; +import { UsersIcon } from "@repo/rdx-ui/lib/icons"; import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; @@ -21,7 +22,12 @@ export function PersonEditorDialog({ onOpenChange={onOpenChange} open={open} size="medium" - title={t(formProps.person ? "editPerson" : "newPerson")} + title={ + + + } > diff --git a/modules/companies/src/web/people/shared/ui/person-form.tsx b/modules/companies/src/web/people/shared/ui/person-form.tsx index 9957a81..90aa11d 100644 --- a/modules/companies/src/web/people/shared/ui/person-form.tsx +++ b/modules/companies/src/web/people/shared/ui/person-form.tsx @@ -1,4 +1,5 @@ import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; +import { FormSection } from "@repo/rdx-ui/components/form-section"; import { SwitchField } from "@repo/rdx-ui/components/switch-field"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; @@ -11,65 +12,75 @@ import { CompanyFeedback } from "../../../companies/shared"; import { usePersonFormController } from "../controllers"; import type { PersonFormProps } from "../types"; +import { PersonPhotoField } from "./person-photo-field"; + export function PersonForm(props: PersonFormProps) { - const { person, requireActive = false } = props; - const { form, mutation, submit, setPhotoFile } = usePersonFormController(props); + const { person, companyId, requireActive = false } = props; + const { form, mutation, submit, photoFile, setPhotoFile } = usePersonFormController(props); const { t } = useTranslation("companies"); const id = useId(); return (
-
-
- - setPhotoFile(event.target.files?.[0] ?? null)} +
+
+ form.setValue("active", active, { shouldDirty: true })} + /> + + form.setValue("isContact", isContact, { shouldDirty: true }) + } />
-
- - -
-
- - -
-
- - -
-
- - -
+ {person?.active && !form.watch("active") && ( +

{t("deactivationHelp")}

+ )}
- form.setValue("active", active, { shouldDirty: true })} - /> - - form.setValue("isContact", isContact, { shouldDirty: true }) - } - /> - {person?.active && !form.watch("active") && ( -

{t("deactivationHelp")}

- )} + + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + +
+
+ + +
+
+
0} /> -

{t("personHelp")}

+ diff --git a/modules/companies/src/web/people/shared/ui/person-photo-field.tsx b/modules/companies/src/web/people/shared/ui/person-photo-field.tsx new file mode 100644 index 0000000..38e0f35 --- /dev/null +++ b/modules/companies/src/web/people/shared/ui/person-photo-field.tsx @@ -0,0 +1,82 @@ +import { CameraIcon } from "@repo/rdx-ui/lib/icons"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/shadcn-ui/components/avatar"; +import { Button } from "@repo/shadcn-ui/components/button"; +import { Label } from "@repo/shadcn-ui/components/label"; +import { useEffect, useId, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { PersonDTO } from "../../../../common"; + +export function PersonPhotoField({ + companyId, + person, + file, + onChange, + disabled, +}: { + companyId: string; + person?: PersonDTO | undefined; + file: File | null; + onChange: (file: File | null) => void; + disabled: boolean; +}) { + const { t } = useTranslation("companies"); + const id = useId(); + const inputRef = useRef(null); + const [preview, setPreview] = useState(undefined); + + useEffect(() => { + if (!file) { + setPreview(undefined); + + return; + } + const url = URL.createObjectURL(file); + + setPreview(url); + + return () => URL.revokeObjectURL(url); + }, [file]); + + const currentPhoto = person?.photoVersion + ? `/api/companies/${companyId}/people/${person.id}/photo?version=${person.photoVersion}` + : undefined; + + return ( +
+ +
+ + onChange(event.target.files?.[0] ?? null)} + ref={inputRef} + tabIndex={-1} + type="file" + /> + +

+ {file ? file.name : t("personForm.photoHelp")} +

+
+
+ ); +} diff --git a/modules/resources/src/api/domain/resource-catalog-seed.ts b/modules/resources/src/api/domain/resource-catalog-seed.ts index c14192e..2c542e1 100644 --- a/modules/resources/src/api/domain/resource-catalog-seed.ts +++ b/modules/resources/src/api/domain/resource-catalog-seed.ts @@ -1,5 +1,7 @@ import { UniqueID } from "@repo/rdx-ddd"; +import { BUILTIN_RESOURCE_TEMPLATE_ICONS } from "../../common/resource-template-icons"; + import type { ResourceCatalog } from "./resource-catalog.aggregate"; import type { ResourceDefinitionSpec, ResourceFieldType } from "./resource-definition"; import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo"; @@ -123,7 +125,11 @@ const FIELDS: readonly [string, string, ResourceFieldType, string, (readonly str "Marca si la renovación se realiza automáticamente. Ejemplo: Sí si el proveedor carga el siguiente periodo.", ], ]; -const TEMPLATES: readonly [string, string, readonly string[]][] = [ +const TEMPLATES: readonly [ + keyof typeof BUILTIN_RESOURCE_TEMPLATE_ICONS, + string, + readonly string[], +][] = [ ["mailbox", "Buzón de correo", ["email", "provider", "context"]], ["access", "Recurso de acceso", ["reference"]], ["shared_folder", "Carpeta compartida", ["unc", "server", "description"]], @@ -210,6 +216,7 @@ export function seedResourceCatalog(catalog: ResourceCatalog) { add(`template:${key}`, { kind: "template", + icon: BUILTIN_RESOURCE_TEMPLATE_ICONS[key], ...(key === "software_license" ? { usage: "license" as const } : key === "support" || key === "os" diff --git a/modules/resources/src/api/domain/resource-catalog.test.ts b/modules/resources/src/api/domain/resource-catalog.test.ts index ab3592e..5518170 100644 --- a/modules/resources/src/api/domain/resource-catalog.test.ts +++ b/modules/resources/src/api/domain/resource-catalog.test.ts @@ -4,9 +4,12 @@ import { test } from "node:test"; import { UniqueID } from "@repo/rdx-ddd"; import { Maybe } from "@repo/rdx-utils"; +import { BUILTIN_RESOURCE_TEMPLATE_ICONS } from "../../common/resource-template-icons"; + import { ResourceCatalog } from "./resource-catalog.aggregate"; import { seedResourceCatalog } from "./resource-catalog-seed"; import type { ResourceDefinitionSpec } from "./resource-definition"; +import { ResourceDefinitionSchema } from "./resource-definition"; import { ResourceDefinition } from "./resource-definition.entity"; import { ResourceDefinitionValues } from "./value-objects/resource-definition-values.vo"; @@ -354,3 +357,95 @@ test("sistema operativo: plantilla con uso explícito, copia y ampliación sin s assert.equal(template.values.data.fields.length, 3); assert.equal(seedResourceCatalog(catalog), catalog); }); + +test("iconos de recursos: todas las plantillas de serie tienen su icono y el arranque no repone los retirados", () => { + const initial = seedResourceCatalog(ResourceCatalog.create()); + + for (const [key, icon] of Object.entries(BUILTIN_RESOURCE_TEMPLATE_ICONS)) { + const template = initial.definitions.find( + (item) => item.stockKey.getOrUndefined() === `template:${key}` + ); + + assert.ok(template?.values.data.kind === "template"); + assert.equal(template.values.data.icon, icon); + } + const template = initial.definitions.find( + (item) => item.stockKey.getOrUndefined() === "template:mailbox" + ); + + assert.ok(template?.values.data.kind === "template"); + const { icon, ...withoutIcon } = template.values.data; + + assert.equal(icon, "mail"); + const removed = initial.save(Maybe.none(), Maybe.some(template.id), values(withoutIcon)); + + assert.ok(removed.isSuccess); + const restarted = seedResourceCatalog(removed.data); + const saved = restarted.definitions.find((item) => item.id.equals(template.id)); + + assert.ok(saved?.values.data.kind === "template"); + assert.equal(saved.values.data.icon, undefined); + assert.deepEqual(saved.values.data.fields, template.values.data.fields); + assert.deepEqual(saved.bindings, template.bindings); + assert.equal(seedResourceCatalog(restarted), restarted); +}); + +test("iconos de recursos: cambiar y retirar el icono conserva identidad y asociaciones, con guardado idempotente", () => { + let catalog = seedResourceCatalog(ResourceCatalog.create()); + const fieldId = catalog.definitions[0]?.id.toPrimitive(); + + assert.ok(fieldId); + for (const scope of [Maybe.none(), Maybe.some(UniqueID.generateNewID())]) { + const input = { + kind: "template", + name: scope.getOrUndefined() ? "Recurso local con icono" : "Recurso global con icono", + description: "", + fields: [{ fieldId, required: false }], + } satisfies ResourceDefinitionSpec; + const created = catalog.save(scope, Maybe.none(), values(input)); + + assert.ok(created.isSuccess); + const template = created.data.definitions.at(-1); + + assert.ok(template); + const changed = created.data.save( + scope, + Maybe.some(template.id), + values({ ...input, icon: "folder" }) + ); + + assert.ok(changed.isSuccess); + const saved = changed.data.definitions.find((item) => item.id.equals(template.id)); + + assert.ok(saved?.values.data.kind === "template"); + assert.equal(saved.values.data.icon, "folder"); + assert.ok(saved.bindings[0]?.id.equals(template.bindings[0]?.id)); + const repeated = changed.data.save(scope, Maybe.some(saved.id), saved.values); + + assert.ok(repeated.isSuccess); + assert.equal(repeated.data, changed.data); + const removed = changed.data.save(scope, Maybe.some(saved.id), values(input)); + + assert.ok(removed.isSuccess); + const cleared = removed.data.definitions.find((item) => item.id.equals(template.id)); + + assert.ok(cleared?.values.data.kind === "template"); + assert.equal(cleared.values.data.icon, undefined); + catalog = removed.data; + } +}); + +test("iconos de recursos: claves desconocidas, cadenas vacías e iconos de campos se rechazan", () => { + const template = { + kind: "template", + name: "Plantilla", + description: "", + fields: [{ fieldId: UniqueID.generateNewID().toPrimitive(), required: false }], + }; + + for (const icon of ["unknown-icon", "", null]) { + assert.equal(ResourceDefinitionSchema.safeParse({ ...template, icon }).success, false); + } + assert.equal(ResourceDefinitionSchema.safeParse({ ...field, icon: "mail" }).success, false); + assert.ok(ResourceDefinitionSchema.safeParse(template).success); +}); diff --git a/modules/resources/src/api/domain/resource-definition.ts b/modules/resources/src/api/domain/resource-definition.ts index 968c348..e067871 100644 --- a/modules/resources/src/api/domain/resource-definition.ts +++ b/modules/resources/src/api/domain/resource-definition.ts @@ -1,5 +1,7 @@ import { z } from "zod/v4"; +import { ResourceTemplateIconSchema } from "../../common/resource-template-icons"; + import { validTemplateSections } from "./template-sections"; const title = z.string().min(1).max(100); @@ -30,6 +32,7 @@ export const ResourceDefinitionSchema = z.discriminatedUnion("kind", [ z .strictObject({ kind: z.literal("template"), + icon: ResourceTemplateIconSchema.optional(), name: title, description: z.string().max(1000), fields: z.array(TemplateFieldSchema).min(1).max(100), diff --git a/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts b/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts index 4a09fa9..4358321 100644 --- a/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts +++ b/modules/resources/src/api/domain/value-objects/resource-definition-values.vo.ts @@ -104,6 +104,7 @@ export class ResourceDefinitionValues { a.kind === "template" && b.kind === "template" && a.usage === b.usage && + a.icon === b.icon && sameTemplateSections(a.sections ?? [], b.sections ?? []) && a.fields.length === b.fields.length && a.fields.every( diff --git a/modules/resources/src/api/infrastructure/persistence/resource-catalog-relational.ts b/modules/resources/src/api/infrastructure/persistence/resource-catalog-relational.ts index dc07187..df26391 100644 --- a/modules/resources/src/api/infrastructure/persistence/resource-catalog-relational.ts +++ b/modules/resources/src/api/infrastructure/persistence/resource-catalog-relational.ts @@ -19,6 +19,7 @@ type RelationalDefinition = Omit & { kind: string; name: string; description: string; + icon: string | null; fieldType: string | null; unit: string | null; usage: string | null; @@ -94,6 +95,7 @@ export async function readDefinitions( values = { ...common, kind: row.kind, + ...(row.icon ? { icon: row.icon } : {}), ...(row.usage ? { usage: row.usage } : {}), fields: bindings .filter((binding) => binding.templateId === row.id) @@ -147,6 +149,7 @@ export async function writeDefinition( kind: values.kind, name: values.name, description: values.description, + icon: values.kind === "template" ? (values.icon ?? null) : null, fieldType: values.kind === "field" ? values.type : null, unit: values.kind === "field" ? values.unit : null, usage: values.kind === "template" ? (values.usage ?? null) : null, diff --git a/modules/resources/src/api/infrastructure/persistence/resource-catalog.model.ts b/modules/resources/src/api/infrastructure/persistence/resource-catalog.model.ts index b9fe64d..403b785 100644 --- a/modules/resources/src/api/infrastructure/persistence/resource-catalog.model.ts +++ b/modules/resources/src/api/infrastructure/persistence/resource-catalog.model.ts @@ -1,6 +1,8 @@ import type { InferAttributes, InferCreationAttributes, Sequelize } from "sequelize"; import { DataTypes, Model, QueryTypes } from "sequelize"; +import { BUILTIN_RESOURCE_TEMPLATE_ICONS } from "../../../common/resource-template-icons"; + import { definitionColumns, initializeResourceCatalogSchema } from "./resource-relational-schema"; export class ResourceCatalogModel extends Model< @@ -105,6 +107,20 @@ export async function migrateResourceCatalog(database: Sequelize) { await query.addIndex(table, [...fields], { unique: true, name }); } } + const columns = await query.describeTable("resource_definitions"); + + if (!columns.icon) { + await query.addColumn("resource_definitions", "icon", definitionColumns.icon); + // La asignación inicial solo acompaña a la nueva columna; no repone iconos retirados. + await database.transaction(async (transaction) => { + for (const [key, icon] of Object.entries(BUILTIN_RESOURCE_TEMPLATE_ICONS)) { + await database.query( + "UPDATE resource_definitions SET icon=:icon WHERE stockKey=:stockKey AND kind='template' AND icon IS NULL", + { replacements: { icon, stockKey: `template:${key}` }, transaction } + ); + } + }); + } await initializeResourceCatalogSchema(database); // Clasificación explícita de la plantilla de serie existente, sin alterar copias ni campos. await database.query( diff --git a/modules/resources/src/api/infrastructure/persistence/resource-relational-schema.ts b/modules/resources/src/api/infrastructure/persistence/resource-relational-schema.ts index 691a3e6..a32193c 100644 --- a/modules/resources/src/api/infrastructure/persistence/resource-relational-schema.ts +++ b/modules/resources/src/api/infrastructure/persistence/resource-relational-schema.ts @@ -15,6 +15,7 @@ export const definitionColumns = { kind: { type: DataTypes.STRING(12), allowNull: false }, name: title, description: { type: DataTypes.TEXT, allowNull: false }, + icon: { type: DataTypes.STRING(30), allowNull: true }, fieldType: { type: DataTypes.STRING(12), allowNull: true }, unit: { type: DataTypes.STRING(30), allowNull: true }, usage: { type: DataTypes.STRING(15), allowNull: true }, diff --git a/modules/resources/src/common/locales/en.json b/modules/resources/src/common/locales/en.json index d54af8b..41811ad 100644 --- a/modules/resources/src/common/locales/en.json +++ b/modules/resources/src/common/locales/en.json @@ -1,4 +1,23 @@ { + "templateIcon": { + "label": "Icon (optional)", + "none": "No icon", + "help": "Shown in the catalog and in resources using this template.", + "options": { + "mail": "Mail", + "key": "Access", + "folder": "Folder", + "headphones": "Support", + "monitor": "Operating system", + "layers": "Application", + "shield": "Certificate", + "file-key": "License", + "box": "Resource" + } + }, + "resourcePanel": "Resource details", + "closeResourcePanel": "Close resource panel", + "showResourcePanel": "Show resource panel", "allResources": "All", "filterByTemplate": "Filter by template", "inventory": "Resources", diff --git a/modules/resources/src/common/locales/es.json b/modules/resources/src/common/locales/es.json index 2278547..313c5e2 100644 --- a/modules/resources/src/common/locales/es.json +++ b/modules/resources/src/common/locales/es.json @@ -1,4 +1,23 @@ { + "templateIcon": { + "label": "Icono (opcional)", + "none": "Sin icono", + "help": "Se muestra en el catálogo y en los recursos de esta plantilla.", + "options": { + "mail": "Correo", + "key": "Acceso", + "folder": "Carpeta", + "headphones": "Asistencia", + "monitor": "Sistema operativo", + "layers": "Aplicación", + "shield": "Certificado", + "file-key": "Licencia", + "box": "Recurso" + } + }, + "resourcePanel": "Ficha del recurso", + "closeResourcePanel": "Cerrar panel del recurso", + "showResourcePanel": "Mostrar panel del recurso", "allResources": "Todo", "filterByTemplate": "Filtrar por plantilla", "inventory": "Recursos", diff --git a/modules/resources/src/common/resource-template-icons.ts b/modules/resources/src/common/resource-template-icons.ts new file mode 100644 index 0000000..781b3d6 --- /dev/null +++ b/modules/resources/src/common/resource-template-icons.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; + +export const ResourceTemplateIconSchema = z.enum([ + "mail", + "key", + "folder", + "headphones", + "monitor", + "layers", + "shield", + "file-key", + "box", +]); + +export type ResourceTemplateIconKey = z.infer; + +export const BUILTIN_RESOURCE_TEMPLATE_ICONS = { + mailbox: "mail", + access: "key", + shared_folder: "folder", + support: "headphones", + os: "monitor", + application: "layers", + certificate: "shield", + software_license: "file-key", +} as const satisfies Record; diff --git a/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts b/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts index d82da53..ead88e9 100644 --- a/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts +++ b/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts @@ -30,6 +30,7 @@ export function useCatalogTemplateFormController({ resolver: zodResolver(TemplateFormSchema), defaultValues: { kind: "template", + icon: current?.icon, name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""), description: current?.description ?? "", usage: templateUsage(source), diff --git a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx index 62ba810..de3cf26 100644 --- a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx +++ b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx @@ -1,8 +1,10 @@ import { UniqueID } from "@repo/rdx-ddd"; import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog"; import { FormSection } from "@repo/rdx-ui/components/form-section"; +import { IconPicker } from "@repo/rdx-ui/components/icon-picker"; import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action"; import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor"; +import { XIcon } from "@repo/rdx-ui/lib/icons"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; @@ -10,12 +12,15 @@ import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { useId } from "react"; import { useTranslation } from "react-i18next"; +import { ResourceTemplateIconSchema } from "../../../../common/resource-template-icons"; import { CatalogError } from "../../../catalog/shared/ui/catalog-error"; import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help"; import { CatalogFieldCreateDialog } from "../../../catalog-fields"; import { useCatalogTemplateFormController } from "../controllers"; import type { CatalogTemplateFormProps } from "../types"; +import { ResourceTemplateIcon } from "./resource-template-icon"; + export function CatalogTemplateForm(props: CatalogTemplateFormProps) { const { scope, item, allowQuickCreate = true } = props; const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } = @@ -43,6 +48,29 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) { />
+ { + const icon = ResourceTemplateIconSchema.safeParse(value); + + form.setValue("icon", icon.success ? icon.data : undefined, { shouldDirty: true }); + }} + options={[ + { + value: "", + label: t("templateIcon.none"), + icon: