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.
This commit is contained in:
parent
0f1ad8511a
commit
ece38c741e
@ -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);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -44,22 +44,32 @@ export function AssetSelectionPanel({
|
||||
|
||||
return (
|
||||
<SelectionPanel
|
||||
label={t("assetPanel")}
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">
|
||||
{t("viewAsset")}
|
||||
</Button>
|
||||
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">
|
||||
{t("deleteAsset")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
closeLabel={t("closeAssetPanel")}
|
||||
label={t("assetPanel")}
|
||||
onClose={onClose}
|
||||
title={<>
|
||||
<AssetTemplateIcon
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||
/>
|
||||
{asset.values.name}
|
||||
</>}
|
||||
summary={<AssetPanelSummary asset={asset} lookups={lookups} />}
|
||||
actions={<>
|
||||
<Button onClick={() => onView(asset.id)} size="sm" variant="outline">{t("viewAsset")}</Button>
|
||||
<Button disabled={!canEdit} onClick={() => onEdit(asset.id)} size="sm" variant="outline">{t("edit")}</Button>
|
||||
<Button onClick={() => onDelete(asset.id)} size="sm" variant="destructive">{t("deleteAsset")}</Button>
|
||||
</>}
|
||||
title={
|
||||
<>
|
||||
<AssetTemplateIcon
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||
/>
|
||||
{asset.values.name}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AssetDetail asset={asset} compact lookups={lookups}>
|
||||
<DetailSection title={t("relations")}>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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={
|
||||
<span className="flex items-center gap-2">
|
||||
<UsersIcon aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||
{t(formProps.person ? "editPerson" : "newPerson")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<PersonForm {...formProps} key={formProps.person?.id ?? "new"} />
|
||||
</FormDialog>
|
||||
|
||||
@ -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 (
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-photo`}>{t("photoOptional")}</Label>
|
||||
<Input
|
||||
id={`${id}-photo`}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
type="file"
|
||||
onChange={(event) => setPhotoFile(event.target.files?.[0] ?? null)}
|
||||
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-x-8 gap-y-3">
|
||||
<SwitchField
|
||||
checked={form.watch("active")}
|
||||
disabled={requireActive}
|
||||
id={`${id}-active`}
|
||||
label={t("activePerson")}
|
||||
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
|
||||
/>
|
||||
<SwitchField
|
||||
checked={form.watch("isContact")}
|
||||
id={`${id}-contact`}
|
||||
label={t("isContact")}
|
||||
onCheckedChange={(isContact) =>
|
||||
form.setValue("isContact", isContact, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("generalPosition")}</Label>
|
||||
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("emailOptional")}</Label>
|
||||
<Input id={`${id}-email`} maxLength={254} type="email" {...form.register("email")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
|
||||
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
|
||||
</div>
|
||||
{person?.active && !form.watch("active") && (
|
||||
<p className="text-sm text-muted-foreground">{t("deactivationHelp")}</p>
|
||||
)}
|
||||
</div>
|
||||
<SwitchField
|
||||
checked={form.watch("active")}
|
||||
disabled={requireActive}
|
||||
id={`${id}-active`}
|
||||
label={t("activePerson")}
|
||||
onCheckedChange={(active) => form.setValue("active", active, { shouldDirty: true })}
|
||||
/>
|
||||
<SwitchField
|
||||
checked={form.watch("isContact")}
|
||||
id={`${id}-contact`}
|
||||
label={t("isContact")}
|
||||
onCheckedChange={(isContact) =>
|
||||
form.setValue("isContact", isContact, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
{person?.active && !form.watch("active") && (
|
||||
<p className="text-sm text-muted-foreground">{t("deactivationHelp")}</p>
|
||||
)}
|
||||
<FormSection title={t("personForm.identity")} variant="plain">
|
||||
<PersonPhotoField
|
||||
companyId={companyId}
|
||||
disabled={mutation.isPending}
|
||||
file={photoFile}
|
||||
onChange={setPhotoFile}
|
||||
person={person}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-position`}>{t("generalPosition")}</Label>
|
||||
<Input id={`${id}-position`} maxLength={100} {...form.register("position")} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title={t("personForm.contact")} variant="plain">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-email`}>{t("emailOptional")}</Label>
|
||||
<Input id={`${id}-email`} maxLength={254} type="email" {...form.register("email")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-phone`}>{t("phoneOptional")}</Label>
|
||||
<Input id={`${id}-phone`} maxLength={50} type="tel" {...form.register("phone")} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<CompanyFeedback
|
||||
error={mutation.error}
|
||||
invalid={Object.keys(form.formState.errors).length > 0}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("personHelp")}</p>
|
||||
|
||||
<EntityHelp kind="person" />
|
||||
</FormBody>
|
||||
<FormActions>
|
||||
|
||||
@ -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<HTMLInputElement>(null);
|
||||
const [preview, setPreview] = useState<string | undefined>(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 (
|
||||
<div className="flex items-center gap-4 pb-1">
|
||||
<Avatar aria-hidden="true" className="size-20 shrink-0">
|
||||
<AvatarImage alt="" src={preview ?? currentPhoto} />
|
||||
<AvatarFallback className="border border-dashed bg-muted/40">
|
||||
<CameraIcon className="size-7 text-muted-foreground/70" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<Label htmlFor={id}>{t("photoOptional")}</Label>
|
||||
<input
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
aria-describedby={`${id}-help`}
|
||||
className="sr-only"
|
||||
disabled={disabled}
|
||||
id={id}
|
||||
onChange={(event) => onChange(event.target.files?.[0] ?? null)}
|
||||
ref={inputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
<Button
|
||||
aria-describedby={`${id}-help`}
|
||||
disabled={disabled}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t(file || currentPhoto ? "personForm.changePhoto" : "personForm.choosePhoto")}
|
||||
</Button>
|
||||
<p className="break-words text-xs text-muted-foreground" id={`${id}-help`}>
|
||||
{file ? file.name : t("personForm.photoHelp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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<UniqueID>(), 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);
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -19,6 +19,7 @@ type RelationalDefinition = Omit<DefinitionRow, "values"> & {
|
||||
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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
26
modules/resources/src/common/resource-template-icons.ts
Normal file
26
modules/resources/src/common/resource-template-icons.ts
Normal file
@ -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<typeof ResourceTemplateIconSchema>;
|
||||
|
||||
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<string, ResourceTemplateIconKey>;
|
||||
@ -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),
|
||||
|
||||
@ -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) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IconPicker
|
||||
disabled={mutation.isPending}
|
||||
label={t("templateIcon.label")}
|
||||
onChange={(value) => {
|
||||
const icon = ResourceTemplateIconSchema.safeParse(value);
|
||||
|
||||
form.setValue("icon", icon.success ? icon.data : undefined, { shouldDirty: true });
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: t("templateIcon.none"),
|
||||
icon: <XIcon aria-hidden="true" className="size-4" />,
|
||||
},
|
||||
...ResourceTemplateIconSchema.options.map((icon) => ({
|
||||
value: icon,
|
||||
label: t(`templateIcon.options.${icon}`),
|
||||
icon: <ResourceTemplateIcon icon={icon} />,
|
||||
})),
|
||||
]}
|
||||
value={form.watch("icon") ?? ""}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("templateIcon.help")}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-usage`}>{t("templateUsage")}</Label>
|
||||
<SelectWithAction
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
import {
|
||||
BoxIcon,
|
||||
FileKeyIcon,
|
||||
FolderIcon,
|
||||
HeadphonesIcon,
|
||||
KeyRoundIcon,
|
||||
LayersIcon,
|
||||
MailIcon,
|
||||
MonitorIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "@repo/rdx-ui/lib/icons";
|
||||
|
||||
import type { ResourceTemplateIconKey } from "../../../../common/resource-template-icons";
|
||||
|
||||
const ICONS = {
|
||||
mail: MailIcon,
|
||||
key: KeyRoundIcon,
|
||||
folder: FolderIcon,
|
||||
headphones: HeadphonesIcon,
|
||||
monitor: MonitorIcon,
|
||||
layers: LayersIcon,
|
||||
shield: ShieldCheckIcon,
|
||||
"file-key": FileKeyIcon,
|
||||
box: BoxIcon,
|
||||
} satisfies Record<ResourceTemplateIconKey, typeof MailIcon>;
|
||||
|
||||
export function ResourceTemplateIcon({
|
||||
icon,
|
||||
className = "size-4 shrink-0 text-muted-foreground",
|
||||
}: {
|
||||
icon: ResourceTemplateIconKey | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!icon) {
|
||||
return null;
|
||||
}
|
||||
const Icon = ICONS[icon];
|
||||
|
||||
return <Icon aria-hidden="true" className={className} />;
|
||||
}
|
||||
@ -4,6 +4,7 @@ import { TemplateFieldSummary } from "@repo/rdx-ui/components/template-field-sum
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
|
||||
import { CatalogHelp } from "../../shared";
|
||||
|
||||
export function CatalogDefinitionDialog({
|
||||
@ -31,7 +32,12 @@ export function CatalogDefinitionDialog({
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={t("viewDefinition", { name: values.name })}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
{values.kind === "template" && <ResourceTemplateIcon icon={values.icon} />}
|
||||
{t("viewDefinition", { name: values.name })}
|
||||
</span>
|
||||
}
|
||||
wide={values.kind === "template"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
||||
@ -72,3 +72,46 @@ test("los formularios tienen ayuda inferior y selector de campo con alta integra
|
||||
assert.ok(template.includes("Todos son opcionales inicialmente"));
|
||||
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
|
||||
});
|
||||
|
||||
test("iconos de recursos: catálogo y selector traducido conservan la selección al editar y copiar", async () => {
|
||||
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||
const mailbox = catalog.items.find((item) => item.builtinKey === "template:mailbox");
|
||||
|
||||
assert.ok(mailbox);
|
||||
for (const [language, label, none, mail] of [
|
||||
["es", "Icono (opcional)", "Sin icono", "Correo"],
|
||||
["en", "Icon (optional)", "No icon", "Mail"],
|
||||
]) {
|
||||
assert.ok(language && label && none && mail);
|
||||
const list = await render(language, <ResourceCatalogPage scope="global" />);
|
||||
|
||||
for (const icon of [
|
||||
"mail",
|
||||
"key-round",
|
||||
"folder",
|
||||
"headphones",
|
||||
"monitor",
|
||||
"layers",
|
||||
"shield-check",
|
||||
"file-key",
|
||||
]) {
|
||||
assert.ok(list.includes(`lucide-${icon}`));
|
||||
}
|
||||
for (const source of [{ item: mailbox }, { copy: mailbox }]) {
|
||||
const html = await render(
|
||||
language,
|
||||
<CatalogTemplateForm
|
||||
{...source}
|
||||
items={catalog.items}
|
||||
onSaved={() => undefined}
|
||||
scope="global"
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.includes(label));
|
||||
assert.ok(html.includes(none));
|
||||
assert.ok(html.includes(`aria-label="${mail}" aria-pressed="true"`));
|
||||
assert.ok(html.includes("lucide-mail"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
|
||||
|
||||
export function CatalogName({
|
||||
item,
|
||||
@ -22,10 +23,11 @@ export function CatalogName({
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
className="inline-flex items-center gap-2 font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onClick={onView}
|
||||
type="button"
|
||||
>
|
||||
{values.kind === "template" && <ResourceTemplateIcon icon={values.icon} />}
|
||||
{values.name}
|
||||
</button>
|
||||
{values.description && <p className="text-xs text-muted-foreground">{values.description}</p>}
|
||||
|
||||
@ -1 +1 @@
|
||||
export { ResourceDetailDialog } from "./ui";
|
||||
export { ResourceDetailDialog, ResourceSelectionPanel } from "./ui";
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export { ResourceDetailDialog } from "./resource-detail-dialog";
|
||||
export { ResourceSelectionPanel } from "./resource-selection-panel";
|
||||
|
||||
@ -9,8 +9,10 @@ import { IntegrationSummary } from "./integration-summary";
|
||||
export function ResourceDetail({
|
||||
resource,
|
||||
references = [],
|
||||
compact = false,
|
||||
}: {
|
||||
resource: ResourceDTO;
|
||||
compact?: boolean;
|
||||
references?: readonly ResourceReferenceDTO[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
@ -24,19 +26,22 @@ export function ResourceDetail({
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
|
||||
<dd>{resource.specification.templateName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
|
||||
<dd>{t(resource.values.active ? "active" : "inactive")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{!compact && <p className="text-sm text-muted-foreground">{t("snapshotNotice")}</p>}
|
||||
{!compact && (
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{t("template")}</dt>
|
||||
<dd>{resource.specification.templateName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">{t("status")}</dt>
|
||||
<dd>{t(resource.values.active ? "active" : "inactive")}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
<FieldSections
|
||||
items={resource.specification.fields}
|
||||
layout={compact ? "rows" : "fields"}
|
||||
renderItem={(field) => (
|
||||
<DetailField
|
||||
key={field.fieldId}
|
||||
@ -58,43 +63,50 @@ export function ResourceDetail({
|
||||
{resource.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{resource.values.notes}</p>
|
||||
)}
|
||||
<h3 className="font-medium">
|
||||
{t("history")} ({resource.history.length})
|
||||
</h3>
|
||||
<ol className="space-y-4">
|
||||
{[...resource.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {t("recordedBy", { name: revision.actorName })}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<FieldSections
|
||||
items={resource.specification.fields}
|
||||
renderItem={(field) => (
|
||||
<div key={field.fieldId}>
|
||||
<dt className="text-muted-foreground">{field.name}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words">
|
||||
{display(revision.values.characteristics[field.fieldId])}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
sections={resource.specification.sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
<IntegrationSummary integration={revision.values.integration} references={references} />
|
||||
{revision.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{!compact && (
|
||||
<>
|
||||
<h3 className="font-medium">
|
||||
{t("history")} ({resource.history.length})
|
||||
</h3>
|
||||
<ol className="space-y-4">
|
||||
{[...resource.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p className="font-medium">
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {t("recordedBy", { name: revision.actorName })}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.name} · {t(revision.values.active ? "active" : "inactive")}
|
||||
</p>
|
||||
<FieldSections
|
||||
items={resource.specification.fields}
|
||||
renderItem={(field) => (
|
||||
<div key={field.fieldId}>
|
||||
<dt className="text-muted-foreground">{field.name}</dt>
|
||||
<dd className="whitespace-pre-wrap break-words">
|
||||
{display(revision.values.characteristics[field.fieldId])}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
sections={resource.specification.sections}
|
||||
ungroupedLabel={t("sections.ungrouped")}
|
||||
values
|
||||
/>
|
||||
<IntegrationSummary
|
||||
integration={revision.values.integration}
|
||||
references={references}
|
||||
/>
|
||||
{revision.values.notes && (
|
||||
<p className="whitespace-pre-wrap break-words">{revision.values.notes}</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
import { EntityStatus } from "@repo/rdx-ui/components/entity-list";
|
||||
import { SelectionPanel } from "@repo/rdx-ui/components/selection-panel";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import type { ResourceDTO, ResourceReferenceDTO } from "../../../../common/inventory";
|
||||
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
|
||||
|
||||
import { ResourceDetail } from "./resource-detail";
|
||||
|
||||
export function ResourceSelectionPanel({
|
||||
resource,
|
||||
template,
|
||||
references,
|
||||
relatedContent,
|
||||
onClose,
|
||||
onView,
|
||||
onEdit,
|
||||
canEdit,
|
||||
}: {
|
||||
resource: ResourceDTO;
|
||||
template?: CatalogItemDTO | undefined;
|
||||
references: readonly ResourceReferenceDTO[];
|
||||
relatedContent: ((resource: ResourceDTO) => ReactNode) | undefined;
|
||||
onClose: () => void;
|
||||
onView: () => void;
|
||||
onEdit: () => void;
|
||||
canEdit: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
|
||||
return (
|
||||
<SelectionPanel
|
||||
actions={
|
||||
<>
|
||||
<Button onClick={onView} size="sm" variant="outline">
|
||||
{t("viewResource")}
|
||||
</Button>
|
||||
<Button disabled={!canEdit} onClick={onEdit} size="sm" variant="outline">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
closeLabel={t("closeResourcePanel")}
|
||||
label={t("resourcePanel")}
|
||||
onClose={onClose}
|
||||
summary={
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>{resource.specification.templateName}</span>
|
||||
<EntityStatus active={resource.values.active}>
|
||||
{t(resource.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<>
|
||||
<ResourceTemplateIcon
|
||||
className="size-5 shrink-0 text-muted-foreground"
|
||||
icon={template?.values.kind === "template" ? template.values.icon : undefined}
|
||||
/>
|
||||
{resource.values.name}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ResourceDetail compact references={references} resource={resource} />
|
||||
{relatedContent?.(resource)}
|
||||
</SelectionPanel>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,10 @@
|
||||
import { useBrowserSearchParams, useEntityNavigation, useListQuery } from "@metis/core/client";
|
||||
import {
|
||||
navigateTo,
|
||||
useBrowserSearchParams,
|
||||
useEntityNavigation,
|
||||
useListQuery,
|
||||
} from "@metis/core/client";
|
||||
import { useCompactDetail } from "@repo/rdx-ui/hooks/use-compact-detail";
|
||||
import { useState } from "react";
|
||||
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
@ -28,10 +34,39 @@ export function useResourcesPageController({
|
||||
|
||||
const [editor, setEditor] = useState<string | undefined>(() => initialEditor(initialCreate));
|
||||
const {
|
||||
selectedId: viewId,
|
||||
selectEntity: setViewId,
|
||||
entityHref,
|
||||
selectedId,
|
||||
selectEntity,
|
||||
entityHref: baseEntityHref,
|
||||
routed,
|
||||
} = useEntityNavigation(`/companies/${companyId}/resources`, initialId);
|
||||
const compact = useCompactDetail();
|
||||
const [localViewId, setLocalViewId] = useState<string | undefined>();
|
||||
const [detailOpen, setDetailOpen] = useState(true);
|
||||
|
||||
const entityHref = (id: string) => {
|
||||
const search = searchParams.toString();
|
||||
|
||||
return `${baseEntityHref(id)}${search ? `?${search}` : ""}`;
|
||||
};
|
||||
|
||||
const setSelectedId = (id: string | undefined) => {
|
||||
if (routed && id) {
|
||||
navigateTo(entityHref(id));
|
||||
} else {
|
||||
selectEntity(id);
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (id: string) => {
|
||||
setDetailOpen(true);
|
||||
setSelectedId(id);
|
||||
if (compact && !routed) {
|
||||
setLocalViewId(id);
|
||||
}
|
||||
};
|
||||
|
||||
const viewId = compact && routed ? selectedId : localViewId;
|
||||
const setViewId = compact && routed ? setSelectedId : setLocalViewId;
|
||||
const query = useListQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
@ -62,6 +97,7 @@ export function useResourcesPageController({
|
||||
});
|
||||
|
||||
const edited = query.data?.resources.find((item) => item.id === editor);
|
||||
const selected = query.data?.resources.find((item) => item.id === selectedId);
|
||||
const viewed = query.data?.resources.find((item) => item.id === viewId);
|
||||
const visible =
|
||||
query.data?.resources.filter((item) => {
|
||||
@ -89,7 +125,13 @@ export function useResourcesPageController({
|
||||
edited,
|
||||
viewed,
|
||||
entityHref,
|
||||
missingView: Boolean(viewId && query.isSuccess && !viewed),
|
||||
selected,
|
||||
selectResource,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||
visible,
|
||||
loading: query.isPending || catalog.isPending,
|
||||
unavailable: (!query.data && query.isError) || (!catalog.data && catalog.isError),
|
||||
};
|
||||
}
|
||||
|
||||
@ -6,12 +6,14 @@ import {
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/shadcn-ui/components/tabs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ResourceTemplateIcon } from "../../../catalog-templates/shared/ui/resource-template-icon";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { ResourceDetailDialog } from "../../detail";
|
||||
import { ResourceDetailDialog, ResourceSelectionPanel } from "../../detail";
|
||||
import { ResourceEditorDialog } from "../../shared";
|
||||
import { useResourcesPageController } from "../controllers";
|
||||
import type { ResourcesPageProps } from "../types";
|
||||
@ -35,6 +37,12 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
entityHref,
|
||||
missingView,
|
||||
visible,
|
||||
loading,
|
||||
unavailable,
|
||||
selected,
|
||||
selectResource,
|
||||
detailOpen,
|
||||
setDetailOpen,
|
||||
} = useResourcesPageController(props);
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
const tabValue = templateId ?? "all";
|
||||
@ -44,111 +52,177 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-6">
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||
{t("newResource")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("resourceScope")}
|
||||
locale={i18n.language}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
<div
|
||||
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_clamp(320px,24vw,400px)] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
|
||||
data-detail-open={detailOpen && Boolean(selected)}
|
||||
data-slot="inventory-layout"
|
||||
>
|
||||
<div className="min-w-0 space-y-6">
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<>
|
||||
{" "}
|
||||
{!detailOpen && selected && (
|
||||
<Button
|
||||
className="hidden inventory:inline-flex"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
{t("showResourcePanel")}
|
||||
</Button>
|
||||
)}
|
||||
<Button disabled={!(query.data && catalog.data)} onClick={() => setEditor("new")}>
|
||||
{t("newResource")}
|
||||
</Button>{" "}
|
||||
</>
|
||||
}
|
||||
description={t("resourceScope")}
|
||||
locale={i18n.language}
|
||||
title={t("inventory")}
|
||||
/>
|
||||
|
||||
{(query.isPending || catalog.isPending) && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
||||
{(query.isError || catalog.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
{loading && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
||||
{(query.isError || catalog.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
<Tabs onValueChange={setTemplateId} value={tabValue}>
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||
<TabsTrigger value="all">{t("allResources")}</TabsTrigger>
|
||||
{templates.map((template) => (
|
||||
<TabsTrigger key={template.id} value={template.id}>
|
||||
{template.values.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent keepMounted value={tabValue}>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => {
|
||||
const summary = characteristicsSummary(
|
||||
item.specification.fields,
|
||||
item.values.characteristics,
|
||||
{ yes: t("yes"), no: t("no") }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={entityHref(item.id)}
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
{item.values.name}
|
||||
</BrowserLink>
|
||||
{summary && (
|
||||
<p className="line-clamp-2 text-xs text-muted-foreground" title={summary}>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (item) => item.specification.templateName,
|
||||
render: (item) => {
|
||||
const template = catalog.data?.items.find(
|
||||
(template) => template.id === item.values.templateId
|
||||
);
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<ResourceTemplateIcon
|
||||
icon={
|
||||
template?.values.kind === "template" ? template.values.icon : undefined
|
||||
}
|
||||
/>
|
||||
{item.specification.templateName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(item.values.active ? "active" : "inactive"),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.active}>
|
||||
{t(item.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setViewId(item.id)}>
|
||||
{t("viewResource")}
|
||||
</EntityAction>
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
filterKey={tabValue}
|
||||
filtersApplied={templateId !== null}
|
||||
items={visible}
|
||||
loading={loading}
|
||||
locale={i18n.language}
|
||||
onClearFilters={clearFilters}
|
||||
onRowClick={(item) => selectResource(item.id)}
|
||||
onSearchChange={setSearch}
|
||||
rowClassName={(item) =>
|
||||
item.id === selected?.id && detailOpen
|
||||
? "bg-accent hover:bg-accent [&>[data-pinned=right]]:bg-accent [&>[data-pinned=right]]:group-hover/entity-row:bg-accent [&>[data-pinned=right]]:group-has-aria-expanded/entity-row:bg-accent"
|
||||
: ""
|
||||
}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("inventory")}
|
||||
unavailable={unavailable}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
{selected && detailOpen && (
|
||||
<ResourceSelectionPanel
|
||||
canEdit={catalog.isSuccess}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
onEdit={() => setEditor(selected.id)}
|
||||
onView={() => setViewId(selected.id)}
|
||||
references={query.data?.references ?? []}
|
||||
relatedContent={relatedContent}
|
||||
resource={selected}
|
||||
template={catalog.data?.items.find(
|
||||
(template) => template.id === selected.values.templateId
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Tabs onValueChange={setTemplateId} value={tabValue}>
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||
<TabsTrigger value="all">{t("allResources")}</TabsTrigger>
|
||||
{templates.map((template) => (
|
||||
<TabsTrigger key={template.id} value={template.id}>
|
||||
{template.values.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent keepMounted value={tabValue}>
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
value: (item) => item.values.name,
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<BrowserLink
|
||||
className="font-medium text-left underline-offset-4 app-link text-primary hover:text-primary focus-visible:outline-2 focus-visible:outline-ring"
|
||||
href={entityHref(item.id)}
|
||||
>
|
||||
{item.values.name}
|
||||
</BrowserLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "template",
|
||||
label: t("template"),
|
||||
value: (item) => item.specification.templateName,
|
||||
render: (item) => <>{item.specification.templateName}</>,
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("status"),
|
||||
value: (item) => t(item.values.active ? "active" : "inactive"),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.active}>
|
||||
{t(item.values.active ? "active" : "inactive")}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
hideable: false,
|
||||
render: (item) => (
|
||||
<EntityRowActions label={t("actions")}>
|
||||
{" "}
|
||||
<div className="contents">
|
||||
<EntityAction onClick={() => setViewId(item.id)}>
|
||||
{t("viewResource")}
|
||||
</EntityAction>
|
||||
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
|
||||
{t("edit")}
|
||||
</EntityAction>
|
||||
</div>{" "}
|
||||
</EntityRowActions>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyMessage={t("empty")}
|
||||
filterKey={tabValue}
|
||||
filtersApplied={templateId !== null}
|
||||
items={visible}
|
||||
loading={query.isPending || catalog.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={clearFilters}
|
||||
onSearchChange={setSearch}
|
||||
search={search}
|
||||
searchPlaceholder={t("search")}
|
||||
title={t("inventory")}
|
||||
unavailable={(!query.data && query.isError) || (!catalog.data && catalog.isError)}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{editor && catalog.data && (
|
||||
<ResourceEditorDialog
|
||||
companyId={companyId}
|
||||
@ -156,7 +230,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
||||
onClose={() => setEditor(undefined)}
|
||||
onSaved={(id) => {
|
||||
setEditor(undefined);
|
||||
setViewId(id);
|
||||
selectResource(id);
|
||||
}}
|
||||
resource={edited}
|
||||
/>
|
||||
|
||||
@ -18,6 +18,8 @@ import { ResourceSchema } from "../../../common/inventory";
|
||||
import { catalogKeys } from "../../catalog/shared/api";
|
||||
import { resourcesEnglish, resourcesSpanish } from "../../shared";
|
||||
import { ResourceCreateProgress } from "../create/ui/resource-create-progress";
|
||||
import { ResourceDetail } from "../detail/ui/resource-detail";
|
||||
import { ResourceSelectionPanel } from "../detail/ui/resource-selection-panel";
|
||||
import { ResourcesPage } from "../list";
|
||||
|
||||
import { inventoryKeys } from "./api";
|
||||
@ -74,6 +76,26 @@ test("recursos: listado con alta modal y sin formulario visible, en español e i
|
||||
assert.equal(html.includes("<form"), false);
|
||||
}
|
||||
});
|
||||
|
||||
function assertResourceRows(
|
||||
table: string,
|
||||
resources: readonly ResourceDTO[],
|
||||
expected: readonly string[],
|
||||
search: string
|
||||
) {
|
||||
for (const resource of resources) {
|
||||
assert.equal(table.includes(resource.values.name), expected.includes(resource.values.name));
|
||||
if (expected.includes(resource.values.name)) {
|
||||
assert.ok(
|
||||
table.includes(
|
||||
`href="/companies/${companyId}/resources/${resource.id}${search.replaceAll("&", "&")}"`
|
||||
)
|
||||
);
|
||||
assert.ok(table.includes("group/entity-row cursor-pointer"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test("Recursos: las pestañas incluyen plantillas vacías e inactivas y combinan identidad y búsqueda", async () => {
|
||||
const catalog = structuredClone(
|
||||
buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none())
|
||||
@ -147,13 +169,36 @@ test("Recursos: las pestañas incluyen plantillas vacías e inactivas y combinan
|
||||
const table = html.match(/<table[\s\S]*?<\/table>/)?.[0];
|
||||
|
||||
assert.ok(table);
|
||||
for (const resource of resources) {
|
||||
assert.equal(table.includes(resource.values.name), expected.includes(resource.values.name));
|
||||
}
|
||||
assert.ok(html.includes('data-detail-open="false"'));
|
||||
assert.equal(html.includes('aria-label="Ficha del recurso"'), false);
|
||||
assert.equal(html.includes('aria-label="Cerrar panel del recurso"'), false);
|
||||
assertResourceRows(table, resources, expected, search);
|
||||
assert.equal((html.match(/role="tab"/g) ?? []).length, templates.length + 1);
|
||||
assert.ok(html.includes(empty.values.name));
|
||||
if (expected.length === 0) {
|
||||
assert.ok(html.includes("No hay resultados para estos criterios."));
|
||||
} else {
|
||||
const selected = resources.find((resource) => resource.values.name === expected[0]);
|
||||
|
||||
assert.ok(selected);
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
location: { pathname: `/companies/${companyId}/resources/${selected.id}`, search },
|
||||
},
|
||||
});
|
||||
const detailHtml = await render("es", <ResourcesPage companyId={companyId} />, {
|
||||
resources,
|
||||
catalog,
|
||||
});
|
||||
const detailTable = detailHtml.match(/<table[\s\S]*?<\/table>/)?.[0];
|
||||
|
||||
assert.ok(detailTable);
|
||||
assert.ok(detailHtml.includes('data-detail-open="true"'));
|
||||
assert.ok(detailHtml.includes('aria-label="Ficha del recurso"'));
|
||||
assert.ok(detailHtml.includes('aria-label="Cerrar panel del recurso"'));
|
||||
assert.equal(detailHtml.includes('role="dialog"'), false);
|
||||
assertResourceRows(detailTable, resources, expected, search);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@ -326,3 +371,154 @@ test("indicador de alta: conserva la plantilla elegida y explica el paso actual
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function summaryResource() {
|
||||
const fields = [
|
||||
{ name: "Producto", type: "text", unit: "", value: " Microsoft 365 " },
|
||||
{ name: "Vacío", type: "text", unit: "", value: " " },
|
||||
{ name: "Cantidad", type: "number", unit: "plazas", value: 0 },
|
||||
{ name: "Renovación", type: "boolean", unit: "", value: false },
|
||||
{ name: "Compartido", type: "boolean", unit: "", value: true },
|
||||
{ name: "Otro campo", type: "text", unit: "", value: "Quinto valor omitido" },
|
||||
].map((field) => ({
|
||||
...field,
|
||||
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||
required: false,
|
||||
choices: [],
|
||||
}));
|
||||
const templateId = UniqueID.generateNewID().toPrimitive();
|
||||
const values = {
|
||||
name: "Suscripción de prueba",
|
||||
templateId,
|
||||
active: false,
|
||||
notes: "Observaciones del recurso",
|
||||
characteristics: Object.fromEntries(fields.map((field) => [field.fieldId, field.value])),
|
||||
};
|
||||
|
||||
return ResourceSchema.parse({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
specification: {
|
||||
templateId,
|
||||
templateName: "Licencia personalizada",
|
||||
fields: fields.map(({ value, ...field }) => field),
|
||||
},
|
||||
values,
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
values,
|
||||
actorId: companyId,
|
||||
actorName: "Técnico de prueba",
|
||||
recordedAt: "2026-10-14T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test("recursos: resumen de plantilla con unidades, booleanos traducidos, cero y límite de cuatro", async () => {
|
||||
const resource = summaryResource();
|
||||
|
||||
for (const [language, yes] of [
|
||||
["es", "Sí"],
|
||||
["en", "Yes"],
|
||||
]) {
|
||||
assert.ok(language && yes);
|
||||
const html = await render(language, <ResourcesPage companyId={companyId} />, {
|
||||
resources: [resource],
|
||||
catalog: { items: [] },
|
||||
});
|
||||
|
||||
assert.ok(html.includes(`Microsoft 365 · 0 plazas · No · ${yes}`));
|
||||
assert.equal(html.includes("Quinto valor omitido"), false);
|
||||
assert.ok(html.includes('data-detail-open="false"'));
|
||||
assert.equal(html.includes('aria-label="Cerrar panel del recurso"'), false);
|
||||
}
|
||||
const empty = { ...resource, values: { ...resource.values, characteristics: {} } };
|
||||
const html = await render("es", <ResourcesPage companyId={companyId} />, {
|
||||
resources: [empty],
|
||||
catalog: { items: [] },
|
||||
});
|
||||
|
||||
assert.ok(html.includes(empty.values.name));
|
||||
assert.equal(html.includes("line-clamp-2"), false);
|
||||
});
|
||||
|
||||
test("recursos: ficha lateral compacta traducida con cierre, acciones y contenido relacionado", async () => {
|
||||
const resource = summaryResource();
|
||||
|
||||
for (const [language, panel, close, status] of [
|
||||
["es", "Ficha del recurso", "Cerrar panel del recurso", "Inactivo"],
|
||||
["en", "Resource details", "Close resource panel", "Inactive"],
|
||||
]) {
|
||||
assert.ok(language && panel && close && status);
|
||||
const html = await render(
|
||||
language,
|
||||
<ResourceSelectionPanel
|
||||
canEdit={false}
|
||||
onClose={() => undefined}
|
||||
onEdit={() => undefined}
|
||||
onView={() => undefined}
|
||||
references={[]}
|
||||
relatedContent={() => <p>Contenido relacionado de prueba</p>}
|
||||
resource={resource}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(html.includes(`aria-label="${panel}"`));
|
||||
assert.ok(html.includes(`aria-label="${close}"`));
|
||||
assert.ok(html.includes(resource.specification.templateName));
|
||||
assert.ok(html.includes(status));
|
||||
assert.ok(html.includes("Observaciones del recurso"));
|
||||
assert.ok(html.includes("Contenido relacionado de prueba"));
|
||||
assert.ok(html.includes("Quinto valor omitido"));
|
||||
assert.equal(html.includes("Técnico de prueba"), false);
|
||||
assert.match(html, /<button[^>]*disabled=""[^>]*>/);
|
||||
const detail = await render(language, <ResourceDetail resource={resource} />);
|
||||
|
||||
assert.ok(detail.includes("Técnico de prueba"));
|
||||
}
|
||||
});
|
||||
|
||||
test("recursos: inventario y panel usan el icono vigente de la plantilla sin cambiar la especificación", async () => {
|
||||
const catalog = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none());
|
||||
const template = catalog.items.find((item) => item.builtinKey === "template:mailbox");
|
||||
|
||||
assert.ok(template?.values.kind === "template");
|
||||
const source = summaryResource();
|
||||
const resource = {
|
||||
...source,
|
||||
values: { ...source.values, templateId: template.id },
|
||||
specification: { ...source.specification, templateId: template.id },
|
||||
};
|
||||
const list = await render("es", <ResourcesPage companyId={companyId} />, {
|
||||
resources: [resource],
|
||||
catalog,
|
||||
});
|
||||
|
||||
assert.ok(list.includes("lucide-mail"));
|
||||
const panel = await render(
|
||||
"es",
|
||||
<ResourceSelectionPanel
|
||||
canEdit
|
||||
onClose={() => undefined}
|
||||
onEdit={() => undefined}
|
||||
onView={() => undefined}
|
||||
references={[]}
|
||||
relatedContent={undefined}
|
||||
resource={resource}
|
||||
template={template}
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(panel.includes("lucide-mail"));
|
||||
const withoutIcon = { ...template, values: { ...template.values, icon: undefined } };
|
||||
const cleared = await render("es", <ResourcesPage companyId={companyId} />, {
|
||||
resources: [resource],
|
||||
catalog: { items: [withoutIcon] },
|
||||
});
|
||||
|
||||
assert.equal(cleared.includes("lucide-mail"), false);
|
||||
assert.equal(resource.specification.templateName, source.specification.templateName);
|
||||
assert.deepEqual(resource.history, source.history);
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { XIcon } from "../lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
|
||||
/** Estructura común de las fichas laterales; el consumidor aporta datos, textos y acciones. */
|
||||
export function SelectionPanel({
|
||||
@ -28,7 +28,9 @@ export function SelectionPanel({
|
||||
>
|
||||
<header className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">{title}</h2>
|
||||
<h2 className="flex min-w-0 items-center gap-2 break-words text-lg font-semibold">
|
||||
{title}
|
||||
</h2>
|
||||
<Button aria-label={closeLabel} onClick={onClose} size="icon-sm" variant="ghost">
|
||||
<XIcon aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
|
||||
@ -12,7 +12,8 @@ export function characteristicsSummary(
|
||||
if (value === undefined || (typeof value === "string" && !value.trim())) {
|
||||
return [];
|
||||
}
|
||||
const text = typeof value === "boolean" ? (value ? labels.yes : labels.no) : String(value).trim();
|
||||
const text =
|
||||
typeof value === "boolean" ? (value ? labels.yes : labels.no) : String(value).trim();
|
||||
|
||||
return [`${text}${field.unit ? ` ${field.unit}` : ""}`];
|
||||
})
|
||||
|
||||
@ -11,6 +11,7 @@ export {
|
||||
ClockIcon,
|
||||
DatabaseIcon,
|
||||
FileKeyIcon,
|
||||
FolderIcon,
|
||||
HardDriveIcon,
|
||||
HeadphonesIcon,
|
||||
HomeIcon,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user