diff --git a/apps/server/src/identity.test.ts b/apps/server/src/identity.test.ts index a1e4cd4..4c9a9cf 100644 --- a/apps/server/src/identity.test.ts +++ b/apps/server/src/identity.test.ts @@ -93,6 +93,8 @@ test("HTTP y MariaDB: configuración única, usuarios, sesiones y protección de await database.query("DELETE FROM printer_configurations"); await database.query("DELETE FROM application_configuration_revisions"); await database.query("DELETE FROM application_configurations"); + await database.query("DELETE FROM software_installation_revisions"); + await database.query("DELETE FROM software_installations"); await database.query("DELETE FROM certificate_configuration_revisions"); await database.query("DELETE FROM certificate_configurations"); await database.query("DELETE FROM person_process_revisions"); diff --git a/apps/server/src/software-installations.integration.ts b/apps/server/src/software-installations.integration.ts index 1b86a87..42f0cbd 100644 --- a/apps/server/src/software-installations.integration.ts +++ b/apps/server/src/software-installations.integration.ts @@ -1,12 +1,14 @@ import assert from "node:assert/strict"; import { InventoryResponseSchema } from "@metis/assets/common"; +import type { SoftwareInstallationInput } from "@metis/companies/common"; import { SoftwareInstallationResponseSchema, SoftwareInstallationsResponseSchema, } from "@metis/companies/common"; import { CatalogResponseSchema, ResourceResponseSchema } from "@metis/resources/common"; import type { Sequelize } from "sequelize"; +import { z } from "zod/v4"; import { migrateSoftwareInstallations } from "../../../modules/companies/src/api/infrastructure/persistence/software-installation-migration"; @@ -31,10 +33,16 @@ export async function verifySoftwareInstallations( ); const template = catalog.items.find((item) => item.builtinKey === "template:os"); const application = catalog.items.find((item) => item.builtinKey === "template:application"); + const licenseTemplate = catalog.items.find( + (item) => item.builtinKey === "template:software_license" + ); const certificate = catalog.items.find((item) => item.builtinKey === "template:certificate"); - assert.ok(host && otherHost && template && application && certificate); + assert.ok(host && otherHost && template && application && certificate && licenseTemplate); assert.ok(template.values.kind === "template" && template.values.usage === "os"); + assert.ok( + licenseTemplate.values.kind === "template" && licenseTemplate.values.usage === "license" + ); const addResource = async (templateId: string, name: string) => { const response = await request(`/resources/${companyId}`, { @@ -46,13 +54,15 @@ export async function verifySoftwareInstallations( return ResourceResponseSchema.parse(await response.json()).resource; }; + const license = await addResource(licenseTemplate.id, "Licencia equipo test"); const os = await addResource(template.id, "Windows 11 Pro test"); const app = await addResource(application.id, "Office equipo test"); const cert = await addResource(certificate.id, "Certificado no instalable como software"); const path = `/software-installations/${companyId}`; - const values = { + const values: SoftwareInstallationInput = { assetId: host.id, resourceId: os.id, + licenseResourceId: null, version: "24H2", state: "configured", notes: null, @@ -61,6 +71,27 @@ export async function verifySoftwareInstallations( assert.equal(foreign.status, 404); assert.equal((await request(path, { values: { ...values, resourceId: cert.id } })).status, 404); + assert.equal( + (await request(path, { values: { ...values, licenseResourceId: cert.id } })).status, + 404 + ); + const foreignLicenseResponse = await request(`/resources/${otherId}`, { + values: { + name: "Licencia ajena", + templateId: licenseTemplate.id, + active: true, + notes: "", + characteristics: {}, + }, + }); + + assert.equal(foreignLicenseResponse.status, 201); + const foreignLicense = ResourceResponseSchema.parse(await foreignLicenseResponse.json()).resource; + + assert.equal( + (await request(path, { values: { ...values, licenseResourceId: foreignLicense.id } })).status, + 404 + ); assert.equal((await request(path, { values: { ...values, profileId: host.id } })).status, 400); assert.equal((await request(path, { values }, "https://other.example")).status, 403); assert.equal((await request(path, { values }, undefined, "a".repeat(64))).status, 403); @@ -86,6 +117,40 @@ export async function verifySoftwareInstallations( assert.equal(updated.history.length, 2); assert.equal(updated.history[0]?.values.version, "24H2"); assert.equal(updated.history[1]?.values.version, "25H2"); + const linkedValues = { ...values, version: "25H2", licenseResourceId: license.id }; + const linked = await save(linkedValues); + + assert.equal(linked.history.length, 3); + assert.equal(linked.history[0]?.values.licenseResourceId, null); + assert.equal(linked.history[2]?.values.licenseResourceId, license.id); + assert.deepEqual(await save(linkedValues), linked); + const deactivateLicense = await request(`/resources/${companyId}/${license.id}/update`, { + values: { ...license.values, active: false }, + }); + + assert.equal(deactivateLicense.status, 200); + const licenseReview = SoftwareInstallationsResponseSchema.parse( + await (await request(path)).json() + ); + + assert.equal( + licenseReview.configurations.find((item) => item.id === created.id)?.needsReview, + true + ); + assert.equal( + ( + await request(`${path}/${created.id}/update`, { + values: { ...linkedValues, version: "26H2" }, + }) + ).status, + 409 + ); + await save({ ...linkedValues, notes: "Revisar licencia" }); + const detached = await save({ ...values, version: "25H2" }); + + assert.equal(detached.needsReview, false); + assert.equal(detached.values.licenseResourceId, null); + assert.equal(detached.history[2]?.values.licenseResourceId, license.id); assert.equal((await request(path, { values: { ...values, resourceId: app.id } })).status, 201); assert.equal( (await request(`/software-installations/${otherId}/${created.id}/update`, { values })).status, @@ -97,6 +162,36 @@ export async function verifySoftwareInstallations( ).configurations.length, 0 ); + const expiryField = catalog.items.find((item) => item.builtinKey === "field:expires"); + + assert.ok(expiryField); + const expiredResponse = await request(`/resources/${companyId}`, { + values: { + name: "Licencia caducada", + templateId: licenseTemplate.id, + active: true, + notes: "", + characteristics: { [expiryField.id]: "2000-01-01" }, + }, + }); + + assert.equal(expiredResponse.status, 201); + const expired = ResourceResponseSchema.parse(await expiredResponse.json()).resource; + const rejectedExpiry = await request(`${path}/${created.id}/update`, { + values: { ...values, version: "25H2", licenseResourceId: expired.id }, + }); + + assert.equal(rejectedExpiry.status, 409); + assert.equal( + z.object({ code: z.string() }).parse(await rejectedExpiry.json()).code, + "SOFTWARE_REFERENCE_UNAVAILABLE" + ); + await migrateSoftwareInstallations(database); + const persisted = SoftwareInstallationsResponseSchema.parse( + await (await request(path)).json() + ).configurations.find((item) => item.id === created.id); + + assert.deepEqual(persisted, detached); const deactivate = await request(`/resources/${companyId}/${os.id}/update`, { values: { ...os.values, active: false }, }); diff --git a/docs/plan-de-desarrollo.md b/docs/plan-de-desarrollo.md index 8ee9215..2188c05 100644 --- a/docs/plan-de-desarrollo.md +++ b/docs/plan-de-desarrollo.md @@ -87,7 +87,7 @@ Varios métodos por activo, catálogo global ampliable con herramientas de serie 1. Vinculación explícita entre un acceso remoto y su VPN previa, si resulta necesaria. -Este orden se concreta antes de cada entrega. El registro de licencias como recursos está implementado; sus asignaciones y relaciones con cuentas, personas, equipos y aplicaciones quedan pendientes. +Este orden se concreta antes de cada entrega. El registro de licencias como recursos y el vínculo informativo con instalaciones del ordenador están implementados. Las asignaciones por cuenta/persona y el cálculo de capacidad quedan pendientes. Véase [Software del ordenador](software-del-ordenador.md). ## Inventario: implementado @@ -105,7 +105,7 @@ Consulta global y por compañía, técnico, tiempos, diagnóstico, trabajo reali ## Licencias de software: registro implementado -Plantilla global protegida, siete campos nuevos y cuatro reutilizados del catálogo de recursos. Registro por compañía, características opcionales, modalidades, criterio de licenciamiento, cantidad contratada, caducidad, renovación e historial. Modales con ayuda específica. Pendientes las asignaciones, capacidad disponible, vínculos con aplicaciones y avisos. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). +Plantilla global protegida, siete campos nuevos y cuatro reutilizados del catálogo de recursos. Registro por compañía, características opcionales, modalidades, criterio de licenciamiento, cantidad contratada, caducidad, renovación e historial. Modales con ayuda específica. Implementado el vínculo opcional con las instalaciones de aplicaciones y sistemas operativos del ordenador, con historial y revisión por inactividad/caducidad. Pendientes las asignaciones por cuenta/persona y la capacidad disponible. Consultar [Recursos, campos y plantillas](recursos-campos-y-plantillas.md). ## Servicios y documentación: pendiente diff --git a/docs/software-del-ordenador.md b/docs/software-del-ordenador.md index 8c94639..705f052 100644 --- a/docs/software-del-ordenador.md +++ b/docs/software-del-ordenador.md @@ -29,3 +29,18 @@ Migración explícita e idempotente al inicializar el módulo. Las escrituras se ## Verificación Pruebas de dominio y persistencia: referencias, SO/aplicaciones, duplicados, ámbito, identidad fija, idempotencia, versiones, revisión, transiciones e historial corrupto. Pruebas HTTP con MariaDB: migración repetida, concurrencia, CSRF/origen, compañía ajena, versiones históricas, recursos inactivos, retirada y eliminación. Pruebas de interfaz es/en: filtrado por ordenador, enlaces, versiones, ausencia de formularios incrustados y ayuda. + + +## Licencia vinculada + +Cada instalación admite una referencia opcional a un recurso de uso **Licencia de software** de la misma compañía. Se elige en el formulario y se muestra como enlace en el listado y en cada revisión del historial. Puede añadirse, sustituirse o quitarse mientras la instalación no esté cerrada. La pareja ordenador/software sigue fija. Repetir los mismos valores no añade historial. + +Este vínculo documenta la licencia indicada por el técnico. No reserva plazas, valida el contrato del fabricante, demuestra cobertura ni deduce que dos productos con nombres parecidos sean compatibles. El software gratuito o una instalación cuya licencia aún no se conoce pueden quedar sin vínculo. Las asignaciones por persona/cuenta y el cálculo de capacidad disponible siguen pendientes. + +Una licencia nueva o un cambio de configuración requieren licencia activa y no caducada. Una instalación abierta con licencia desactivada o caducada aparece para revisión; se pueden completar notas, desvincular la licencia y registrar la retirada. No se elimina ni retira software automáticamente. La caducidad es inclusiva y se consulta mediante el campo de serie de vigencia, usando la fecha UTC igual que el resto del backend. + +El uso `license` permite reconocer también plantillas propias y copias de la plantilla de serie, sin depender del nombre. Una migración idempotente clasifica la plantilla de serie existente; no reclasifica copias anteriores sin uso explícito. El tipo de uso se fija al crear la plantilla, igual que en aplicaciones o certificados. + +`licenseResourceId` es una columna UUID nullable con clave foránea en `software_installations` y `software_installation_revisions`. Las instalaciones y revisiones anteriores conservan NULL: no se inventan relaciones. Las claves foráneas protegen tanto la referencia actual como las históricas. No se añade JSON ni nuevos endpoints. + +Se comprueban referencias de otra compañía o de tipo incorrecto, inactividad/caducidad, idempotencia, desvinculación, revisiones anteriores, migración repetida y enlaces/traducciones es/en. La limpieza de la base exclusiva de pruebas elimina primero las instalaciones y revisiones para permitir ejecuciones consecutivas sin desactivar claves foráneas. diff --git a/modules/assets/src/api/application/asset.repository.ts b/modules/assets/src/api/application/asset.repository.ts index f94e17d..038e194 100644 --- a/modules/assets/src/api/application/asset.repository.ts +++ b/modules/assets/src/api/application/asset.repository.ts @@ -25,6 +25,7 @@ export interface IAssetRepository { company: UniqueID, id: UniqueID ): Promise }, AssetCatalogError>>; + removeWorkstation(company: UniqueID, id: UniqueID): Promise>; removeRelationship(company: UniqueID, id: UniqueID): Promise>; saveWorkstation( companyId: UniqueID, diff --git a/modules/assets/src/api/application/workstation.use-cases.ts b/modules/assets/src/api/application/workstation.use-cases.ts index 03ea2f1..aa816ed 100644 --- a/modules/assets/src/api/application/workstation.use-cases.ts +++ b/modules/assets/src/api/application/workstation.use-cases.ts @@ -11,6 +11,35 @@ import { mapWorkstationInput } from "./workstation-input.mapper"; export class WorkstationUseCases { constructor(private readonly repository: IAssetRepository) {} + remove(companyId: string, id: string) { + const company = UniqueID.create(companyId); + const identity = UniqueID.create(id); + + if (!(company.isSuccess && identity.isSuccess)) { + return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + + return this.repository.removeWorkstation(company.data, identity.data); + } + + decommission(companyId: string, id: string, actorId: string) { + const company = UniqueID.create(companyId); + const identity = UniqueID.create(id); + const actor = UniqueID.create(actorId); + + if (!(company.isSuccess && identity.isSuccess && actor.isSuccess)) { + return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST"))); + } + + return this.repository.saveWorkstation(company.data, (context) => { + const workstation = context.workstations.find((item) => item.id.equals(identity.data)); + + return workstation + ? workstation.decommission(actor.data, new Date().toISOString()) + : Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND")); + }); + } + save(companyId: string, id: string | undefined, input: WorkstationInput, actorId: string) { const company = UniqueID.create(companyId); const actor = UniqueID.create(actorId); diff --git a/modules/assets/src/api/domain/workstation.aggregate.ts b/modules/assets/src/api/domain/workstation.aggregate.ts index 82fcbfa..2cb1bda 100644 --- a/modules/assets/src/api/domain/workstation.aggregate.ts +++ b/modules/assets/src/api/domain/workstation.aggregate.ts @@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils"; import { z } from "zod/v4"; import { AssetCatalogError } from "./asset-definition"; -import type { WorkstationValues } from "./value-objects/workstation-values.vo"; +import { WorkstationValues } from "./value-objects/workstation-values.vo"; import { WorkstationRevision } from "./workstation-revision.entity"; type WorkstationProps = Readonly<{ @@ -53,6 +53,19 @@ export class Workstation extends AggregateRoot { return this.props.history; } + decommission(actorId: UniqueID, recordedAt: string): Result { + const values = WorkstationValues.create({ + ...this.values.toPrimitive(), + active: false, + computerAssetId: null, + personId: null, + assetIds: [], + sharedAssetIds: [], + }); + + return values.isSuccess ? this.update(values.data, actorId, recordedAt) : values; + } + update( values: WorkstationValues, actorId: UniqueID, diff --git a/modules/assets/src/api/infrastructure/http/workstation.routes.ts b/modules/assets/src/api/infrastructure/http/workstation.routes.ts index ce5125d..fa703b0 100644 --- a/modules/assets/src/api/infrastructure/http/workstation.routes.ts +++ b/modules/assets/src/api/infrastructure/http/workstation.routes.ts @@ -60,5 +60,37 @@ export function registerWorkstationRoutes( .catch(next); }); } + for (const action of ["decommission", "delete"] as const) { + router.post(`/:companyId/workstations/:id/${action}`, (request, response, next) => { + const params = AssetParamsSchema.safeParse(request.params); + const actor = z.object({ userId: z.uuid() }).safeParse("session" in request ? request.session : undefined); + + if (!(params.success && actor.success && z.strictObject({}).safeParse(request.body).success && z.strictObject({}).safeParse(request.query).success)) { + fail(response, "INVALID_REQUEST"); + + return; + } + if (action === "delete") { + useCases.remove(params.data.companyId, params.data.id).then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ deleted: true }); + }).catch(next); + + return; + } + useCases.decommission(params.data.companyId, params.data.id, actor.data.userId).then((result) => { + if (!result.isSuccess) { + fail(response, result.error.code); + + return; + } + response.json({ workstation: buildWorkstationResponse(result.data.workstation, result.data.actorNames) }); + }).catch(next); + }); + } app.use("/api/assets", authenticate, router); } diff --git a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts index bfd6615..af5a5cb 100644 --- a/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts +++ b/modules/assets/src/api/infrastructure/persistence/sequelize-asset.repository.ts @@ -172,6 +172,45 @@ export class SequelizeAssetRepository implements IAssetRepository { }); } + removeWorkstation(companyId: UniqueID, id: UniqueID): Promise> { + return this.database.transaction(async (transaction) => { + const loaded = await this.load(companyId, transaction, true); + + if (!loaded.isSuccess) { + return Result.fail(loaded.error); + } + if (!loaded.data.workstations.some((item) => item.id.equals(id))) { + return Result.fail(new AssetCatalogError("WORKSTATION_NOT_FOUND")); + } + const references = await this.database.query<{ tableName: string; columnName: string }>( + "SELECT TABLE_NAME AS tableName,COLUMN_NAME AS columnName FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME='workstations' AND REFERENCED_COLUMN_NAME='id'", + { type: QueryTypes.SELECT, transaction } + ); + const owned = new Set(["workstation_assets", "workstation_members", "workstation_revisions"]); + const replacements = { id: id.toPrimitive(), companyId: companyId.toPrimitive() }; + + for (const reference of references) { + if (owned.has(reference.tableName)) { + continue; + } + const rows = await this.database.query<{ value: number }>( + `SELECT 1 AS value FROM ${sqlIdentifier(reference.tableName)} WHERE ${sqlIdentifier(reference.columnName)}=:id LIMIT 1`, + { replacements, transaction, type: QueryTypes.SELECT } + ); + + if (rows.length) { + return Result.fail(new AssetCatalogError("WORKSTATION_IN_USE")); + } + } + // Las asociaciones de composición se eliminan; sus activos no se borran. + await this.database.query("DELETE FROM workstation_assets WHERE workstationId=:id", { replacements, transaction }); + await this.database.query("DELETE FROM workstation_revisions WHERE workstationId=:id", { replacements, transaction }); + await this.database.query("DELETE FROM workstations WHERE companyId=:companyId AND id=:id", { replacements, transaction }); + + return Result.ok(undefined); + }); + } + private async persistWorkstation( item: Workstation, previous: Workstation | undefined, diff --git a/modules/assets/src/common/locales/en.json b/modules/assets/src/common/locales/en.json index a35e6ad..a3acdbf 100644 --- a/modules/assets/src/common/locales/en.json +++ b/modules/assets/src/common/locales/en.json @@ -361,5 +361,20 @@ "missingAsset": "Asset unavailable." }, "workstationComputer": "Main computer", - "workstationComputerHelp": "Select the computer among this workstation’s exclusive assets. Profiles will reside on it; the remaining assets are peripherals. Use two workstations for two computers." + "workstationComputerHelp": "Select the computer among this workstation’s exclusive assets. Profiles will reside on it; the remaining assets are peripherals. Use two workstations for two computers.", + "workstationLifecycle": { + "decommission": { + "title": "Decommission", + "description": "Decommission “{{name}}” and unlink its responsible person, computer, exclusive and shared assets. History is retained. Assets, profiles and computer software retain their records.", + "pending": "Decommissioning…", + "error": "Could not decommission the workstation. Please try again." + }, + "delete": { + "title": "Delete permanently", + "description": "Delete “{{name}}”, its history and its associations. Assets, people, profiles and software are retained. This cannot be undone. If tickets or interventions refer to it, use Decommission.", + "pending": "Deleting…", + "error": "Could not delete the workstation. Please try again." + }, + "inUse": "Other records, such as tickets or interventions, reference this workstation. Decommission it to retain those references." + } } diff --git a/modules/assets/src/common/locales/es.json b/modules/assets/src/common/locales/es.json index 87ff9a7..fd3b682 100644 --- a/modules/assets/src/common/locales/es.json +++ b/modules/assets/src/common/locales/es.json @@ -361,5 +361,20 @@ "missingAsset": "Activo no disponible." }, "workstationComputer": "Ordenador principal", - "workstationComputerHelp": "Selecciona el ordenador entre los activos exclusivos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos. Si hay dos ordenadores, crea dos puestos." + "workstationComputerHelp": "Selecciona el ordenador entre los activos exclusivos del puesto. Los perfiles se crearán en él; el resto de activos son periféricos. Si hay dos ordenadores, crea dos puestos.", + "workstationLifecycle": { + "decommission": { + "title": "Dar de baja", + "description": "Se dará de baja «{{name}}» y se desvincularán su responsable, ordenador y activos exclusivos y compartidos. Se conservará el historial. Los activos, perfiles y software del ordenador conservarán sus registros.", + "pending": "Dando de baja…", + "error": "No se pudo dar de baja el puesto. Inténtalo de nuevo." + }, + "delete": { + "title": "Eliminar definitivamente", + "description": "Se eliminarán «{{name}}», su historial y sus asociaciones. Los activos, personas, perfiles y software se conservarán. Esta acción no se puede deshacer. Si hay tickets o intervenciones vinculados, utiliza Dar de baja.", + "pending": "Eliminando…", + "error": "No se pudo eliminar el puesto. Inténtalo de nuevo." + }, + "inUse": "El puesto está referenciado por otros registros, por ejemplo tickets o intervenciones. Da de baja el puesto para conservar esas referencias." + } } diff --git a/modules/assets/src/web/index.ts b/modules/assets/src/web/index.ts index 43bf64e..b1597e0 100644 --- a/modules/assets/src/web/index.ts +++ b/modules/assets/src/web/index.ts @@ -7,3 +7,4 @@ export { inventoryApi } from "./shared/api"; export { inventoryKeys } from "./shared/queries"; export { AssetSupport } from "./support-connections"; export { WorkstationEditorDialog, WorkstationForm, WorkstationsPage } from "./workstations"; +export { WorkstationLifecycleDialog } from "./workstations/shared/ui/workstation-lifecycle-dialog"; diff --git a/modules/assets/src/web/workstations/list/ui/workstations-page.tsx b/modules/assets/src/web/workstations/list/ui/workstations-page.tsx index ca1c434..e774f4c 100644 --- a/modules/assets/src/web/workstations/list/ui/workstations-page.tsx +++ b/modules/assets/src/web/workstations/list/ui/workstations-page.tsx @@ -7,8 +7,10 @@ import { EntityTable, } from "@repo/rdx-ui/components/entity-list"; import { Button } from "@repo/shadcn-ui/components/button"; +import { useState } from "react"; import { useTranslation } from "react-i18next"; +import { WorkstationLifecycleDialog } from "../../shared/ui/workstation-lifecycle-dialog"; import { InventoryFeedback } from "../../../shared"; import { WorkstationHistoryDialog } from "../../history"; import { WorkstationEditorDialog } from "../../shared"; @@ -17,6 +19,7 @@ import type { WorkstationsPageProps } from "../types"; export function WorkstationsPage(props: WorkstationsPageProps) { const { companyId, lookups } = props; + const [operation, setOperation] = useState<{ id: string; action: "decommission" | "delete" }>(); const { search, setSearch, @@ -33,6 +36,8 @@ export function WorkstationsPage(props: WorkstationsPageProps) { } = useWorkstationsPageController(props); const { t, i18n } = useTranslation("assets"); + const targeted = query.data?.workstations.find((item) => item.id === operation?.id); + return (
setEditor(item.id)}>{t("edit")} + {(item.values.active || item.values.assetIds.length > 0 || item.values.sharedAssetIds.length > 0 || item.values.personId) && setOperation({ id: item.id, action: "decommission" })}>{t("workstationLifecycle.decommission.title")}} + setOperation({ id: item.id, action: "delete" })}>{t("workstationLifecycle.delete.title")}
{" "} ), @@ -149,6 +156,7 @@ export function WorkstationsPage(props: WorkstationsPageProps) { title={t("workstations")} /> )} + {targeted && operation && setOperation(undefined)} />} {editor && ( void; + onCompleted?: (() => void) | undefined; +}) { + const { t } = useTranslation("assets"); + const client = useQueryClient(); + const [errorCode, setErrorCode] = useState(); + const prefix = `workstationLifecycle.${action}`; + + return { if (!open) { onClose(); } }} + onConfirm={async () => { + setErrorCode(undefined); + try { + const body = await inventoryRequest(`${workstation.companyId}/workstations/${workstation.id}/${action}`, {}); + + if (action === "delete") { + z.strictObject({ deleted: z.literal(true) }).parse(body); + } else { + WorkstationResponseSchema.parse(body); + } + await client.invalidateQueries({ queryKey: inventoryKeys.company(workstation.companyId) }); + onCompleted?.(); + } catch (error) { + setErrorCode(error instanceof InventoryApiError ? error.code : "INTERNAL_ERROR"); + throw error; + } + }} + />; +} diff --git a/modules/companies/src/api/application/application-configuration.repository.ts b/modules/companies/src/api/application/application-configuration.repository.ts index 50c1c98..f1855c1 100644 --- a/modules/companies/src/api/application/application-configuration.repository.ts +++ b/modules/companies/src/api/application/application-configuration.repository.ts @@ -13,7 +13,7 @@ export type ApplicationContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | "support" | "os" | null; + usage: "application" | "certificate" | "support" | "os" | "license" | null; }[]; actors: ReadonlyMap; }>; diff --git a/modules/companies/src/api/application/certificate-configuration.repository.ts b/modules/companies/src/api/application/certificate-configuration.repository.ts index 7f064bf..0441ee4 100644 --- a/modules/companies/src/api/application/certificate-configuration.repository.ts +++ b/modules/companies/src/api/application/certificate-configuration.repository.ts @@ -14,7 +14,7 @@ export type CertificateContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | "support" | "os" | null; + usage: "application" | "certificate" | "support" | "os" | "license" | null; expiresOn: string | null; }[]; actors: ReadonlyMap; diff --git a/modules/companies/src/api/application/software-installation-input.mapper.ts b/modules/companies/src/api/application/software-installation-input.mapper.ts index 505d1d2..efd4679 100644 --- a/modules/companies/src/api/application/software-installation-input.mapper.ts +++ b/modules/companies/src/api/application/software-installation-input.mapper.ts @@ -6,7 +6,11 @@ import { SoftwareInstallationError } from "../domain/software-installation-value import { SoftwareInstallationValues } from "../domain/value-objects/software-installation-values.vo"; import type { SoftwareContext } from "./software-installation.repository"; -import { softwareAvailable, softwareReferences } from "./software-installation-policy"; +import { + softwareAvailable, + softwareLicenseAvailable, + softwareReferences, +} from "./software-installation-policy"; export function mapSoftwareInput( input: SoftwareInstallationInput, @@ -30,6 +34,13 @@ export function mapSoftwareInput( !previous || previous.values.state !== values.state || previous.values.version.getOrUndefined() !== values.version.getOrUndefined(); + const licenseChanged = + previous?.values.licenseResourceId.getOrUndefined()?.toPrimitive() !== + values.licenseResourceId.getOrUndefined()?.toPrimitive(); + + if (licenseChanged && !softwareLicenseAvailable(values, context)) { + return Result.fail(new SoftwareInstallationError("SOFTWARE_REFERENCE_UNAVAILABLE")); + } if ( ["pending", "configured"].includes(values.state) && diff --git a/modules/companies/src/api/application/software-installation-policy.ts b/modules/companies/src/api/application/software-installation-policy.ts index fb9495f..2255da6 100644 --- a/modules/companies/src/api/application/software-installation-policy.ts +++ b/modules/companies/src/api/application/software-installation-policy.ts @@ -3,11 +3,34 @@ import type { SoftwareInstallationValues } from "../domain/value-objects/softwar import type { SoftwareContext } from "./software-installation.repository"; export function softwareReferences(values: SoftwareInstallationValues, context: SoftwareContext) { + const licenseId = values.licenseResourceId.getOrUndefined(); + return ( context.assets.some((item) => item.id.equals(values.assetId)) && context.resources.some( (item) => item.id.equals(values.resourceId) && (item.usage === "application" || item.usage === "os") + ) && + (!licenseId || + context.resources.some((item) => item.id.equals(licenseId) && item.usage === "license")) + ); +} + +export function softwareLicenseAvailable( + values: SoftwareInstallationValues, + context: SoftwareContext +) { + const licenseId = values.licenseResourceId.getOrUndefined(); + const today = new Date().toISOString().slice(0, 10); + + return ( + !licenseId || + context.resources.some( + (item) => + item.id.equals(licenseId) && + item.usage === "license" && + item.active && + (!item.expiresOn || item.expiresOn >= today) ) ); } @@ -20,6 +43,7 @@ export function softwareAvailable(values: SoftwareInstallationValues, context: S item.id.equals(values.resourceId) && item.active && (item.usage === "application" || item.usage === "os") - ) + ) && + softwareLicenseAvailable(values, context) ); } diff --git a/modules/companies/src/api/application/software-installation.repository.ts b/modules/companies/src/api/application/software-installation.repository.ts index 3d15ac3..75be578 100644 --- a/modules/companies/src/api/application/software-installation.repository.ts +++ b/modules/companies/src/api/application/software-installation.repository.ts @@ -10,7 +10,8 @@ export type SoftwareContext = Readonly<{ resources: readonly { id: UniqueID; active: boolean; - usage: "application" | "certificate" | "support" | "os" | null; + expiresOn: string | null; + usage: "application" | "certificate" | "support" | "os" | "license" | null; }[]; actors: ReadonlyMap; }>; diff --git a/modules/companies/src/api/domain/software-installation-values.ts b/modules/companies/src/api/domain/software-installation-values.ts index 291be74..1d20195 100644 --- a/modules/companies/src/api/domain/software-installation-values.ts +++ b/modules/companies/src/api/domain/software-installation-values.ts @@ -5,6 +5,7 @@ import { ConfigurationSchema } from "./access/access-definition"; export const SoftwareInstallationInputSchema = z.strictObject({ assetId: z.uuid(), resourceId: z.uuid(), + licenseResourceId: z.uuid().nullable(), version: z.string().trim().max(100).nullable(), state: ConfigurationSchema, notes: z.string().trim().max(4000).nullable(), diff --git a/modules/companies/src/api/domain/software-installation.test.ts b/modules/companies/src/api/domain/software-installation.test.ts index f256943..b46e36e 100644 --- a/modules/companies/src/api/domain/software-installation.test.ts +++ b/modules/companies/src/api/domain/software-installation.test.ts @@ -21,6 +21,7 @@ const date = "2026-10-12T10:00:00.000Z"; const input: SoftwareInstallationInput = { assetId: asset.toPrimitive(), resourceId: resource.toPrimitive(), + licenseResourceId: null, version: "24H2", state: "pending", notes: null, @@ -28,7 +29,7 @@ const input: SoftwareInstallationInput = { const context: SoftwareContext = { configurations: [], assets: [{ id: asset, available: true }], - resources: [{ id: resource, active: true, usage: "os" }], + resources: [{ id: resource, active: true, expiresOn: null, usage: "os" }], actors: new Map([[actor.toPrimitive(), "David"]]), }; @@ -53,14 +54,14 @@ test("instalaciones: pertenecen al equipo, admiten SO/aplicaciones y rechazan re assert.ok( mapSoftwareInput(input, { ...context, - resources: [{ id: resource, active: true, usage: "application" }], + resources: [{ id: resource, active: true, expiresOn: null, usage: "application" }], }).isSuccess ); assert.ok(!mapSoftwareInput(input, { ...context, assets: [] }).isSuccess); assert.ok( !mapSoftwareInput(input, { ...context, - resources: [{ id: resource, active: true, usage: "certificate" }], + resources: [{ id: resource, active: true, expiresOn: null, usage: "certificate" }], }).isSuccess ); assert.equal( @@ -131,3 +132,85 @@ test("instalaciones: versiones, idempotencia, retirada, revisión y persistencia ) ); }); + +test("licencia del software: opcional, misma compañía y tipo, vigencia, desvinculación e historial idempotente", () => { + const license = UniqueID.generateNewID(); + const licensedInput = { ...input, licenseResourceId: license.toPrimitive() }; + const licenseItem = { id: license, active: true, usage: "license" as const, expiresOn: null }; + const licensedContext = { ...context, resources: [...context.resources, licenseItem] }; + + assert.ok(mapSoftwareInput(licensedInput, licensedContext).isSuccess); + assert.ok(!mapSoftwareInput(licensedInput, context).isSuccess); + assert.ok( + !mapSoftwareInput({ ...input, licenseResourceId: resource.toPrimitive() }, context).isSuccess + ); + assert.equal( + SoftwareInstallationInputSchema.safeParse({ ...input, licenseResourceId: "invalid" }).success, + false + ); + const previous = installation(); + const linked = previous.update(values(licensedInput), actor, date); + + assert.ok(linked.isSuccess); + assert.equal(linked.data.history.length, 2); + assert.equal(linked.data.history[0]?.values.toPrimitive().licenseResourceId, null); + assert.equal( + linked.data.history[1]?.values.toPrimitive().licenseResourceId, + license.toPrimitive() + ); + const same = linked.data.update(values(licensedInput), actor, date); + + assert.ok(same.isSuccess); + assert.equal(same.data, linked.data); + for (const unavailable of [ + { ...licenseItem, active: false }, + { ...licenseItem, expiresOn: "2000-01-01" }, + ]) { + const unavailableContext = { ...context, resources: [...context.resources, unavailable] }; + + assert.ok(!mapSoftwareInput(licensedInput, unavailableContext).isSuccess); + assert.ok(buildSoftwareResponse(linked.data, unavailableContext).needsReview); + assert.ok( + mapSoftwareInput( + { ...licensedInput, notes: "Revisar contrato" }, + unavailableContext, + linked.data + ).isSuccess + ); + assert.ok(mapSoftwareInput(input, unavailableContext, linked.data).isSuccess); + assert.ok( + mapSoftwareInput( + input, + { ...unavailableContext, assets: [{ id: asset, available: false }] }, + linked.data + ).isSuccess + ); + assert.ok( + !mapSoftwareInput( + { ...licensedInput, state: "removal_pending" }, + unavailableContext, + previous + ).isSuccess + ); + assert.ok( + mapSoftwareInput( + { ...licensedInput, state: "never_configured" }, + unavailableContext, + linked.data + ).isSuccess + ); + } + const detached = linked.data.update(values(input), actor, date); + + assert.ok(detached.isSuccess); + assert.equal(detached.data.history.length, 3); + const mapper = new SoftwareInstallationPersistenceMapper(); + const stored = mapper.toPersistence(detached.data); + const restored = mapper.toDomain(stored.root, stored.revisions); + + assert.equal(restored.values.toPrimitive().licenseResourceId, null); + assert.equal(restored.history[1]?.values.toPrimitive().licenseResourceId, license.toPrimitive()); + assert.throws(() => + mapper.toDomain({ ...stored.root, licenseResourceId: license.toPrimitive() }, stored.revisions) + ); +}); diff --git a/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts b/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts index 3743f0b..35610fa 100644 --- a/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts +++ b/modules/companies/src/api/domain/value-objects/software-installation-values.vo.ts @@ -11,6 +11,7 @@ export class SoftwareInstallationValues { private constructor( public readonly assetId: UniqueID, public readonly resourceId: UniqueID, + public readonly licenseResourceId: Maybe, public readonly version: Maybe, public readonly state: SoftwareInstallationInput["state"], public readonly notes: Maybe @@ -30,7 +31,9 @@ export class SoftwareInstallationValues { const asset = UniqueID.create(data.assetId); const resource = UniqueID.create(data.resourceId); - if (!(asset.isSuccess && resource.isSuccess)) { + const license = data.licenseResourceId ? UniqueID.create(data.licenseResourceId) : undefined; + + if (!(asset.isSuccess && resource.isSuccess) || (license && !license.isSuccess)) { return Result.fail(new SoftwareInstallationError("INVALID_REQUEST")); } @@ -38,6 +41,7 @@ export class SoftwareInstallationValues { new SoftwareInstallationValues( asset.data, resource.data, + license?.isSuccess ? Maybe.some(license.data) : Maybe.none(), data.version ? Maybe.some(data.version) : Maybe.none(), data.state, data.notes ? Maybe.some(data.notes) : Maybe.none() @@ -57,6 +61,8 @@ export class SoftwareInstallationValues { return ( this.sameTarget(other) && this.state === other.state && + this.licenseResourceId.getOrUndefined()?.toPrimitive() === + other.licenseResourceId.getOrUndefined()?.toPrimitive() && this.version.getOrUndefined() === other.version.getOrUndefined() && this.notes.getOrUndefined() === other.notes.getOrUndefined() ); @@ -66,6 +72,7 @@ export class SoftwareInstallationValues { return { assetId: this.assetId.toPrimitive(), resourceId: this.resourceId.toPrimitive(), + licenseResourceId: this.licenseResourceId.getOrUndefined()?.toPrimitive() ?? null, version: this.version.getOrUndefined() ?? null, state: this.state, notes: this.notes.getOrUndefined() ?? null, diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts b/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts index 3e96aa5..eab6be6 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize-software-installation.repository.ts @@ -122,14 +122,14 @@ export class SequelizeSoftwareInstallationRepository implements ISoftwareInstall const record = this.mapper.toPersistence(candidate.data); await this.database.query( - "INSERT INTO software_installations (id,companyId,assetId,resourceId,version,state,notes,currentKey) VALUES (:id,:companyId,:assetId,:resourceId,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE version=VALUES(version),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)", + "INSERT INTO software_installations (id,companyId,assetId,resourceId,licenseResourceId,version,state,notes,currentKey) VALUES (:id,:companyId,:assetId,:resourceId,:licenseResourceId,:version,:state,:notes,:currentKey) ON DUPLICATE KEY UPDATE licenseResourceId=VALUES(licenseResourceId),version=VALUES(version),state=VALUES(state),notes=VALUES(notes),currentKey=VALUES(currentKey)", { replacements: record.root, transaction } ); const stored = new Set(previous?.history.map((r) => r.id.toPrimitive())); for (const revision of record.revisions.filter((r) => !stored.has(r.id))) { await this.database.query( - "INSERT INTO software_installation_revisions (id,configurationId,position,actorId,recordedAt,assetId,resourceId,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:assetId,:resourceId,:version,:state,:notes)", + "INSERT INTO software_installation_revisions (id,configurationId,position,actorId,recordedAt,assetId,resourceId,licenseResourceId,version,state,notes) VALUES (:id,:configurationId,:position,:actorId,:recordedAt,:assetId,:resourceId,:licenseResourceId,:version,:state,:notes)", { replacements: revision, transaction } ); } diff --git a/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts b/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts index 388c7b2..4a72f1f 100644 --- a/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts +++ b/modules/companies/src/api/infrastructure/persistence/software-installation-migration.ts @@ -35,6 +35,14 @@ export async function migrateSoftwareInstallations(database: Sequelize) { recordedAt: { type: DataTypes.STRING(24), allowNull: false }, }); } + // Las instalaciones anteriores conservan su historial y no presuponen una licencia. + for (const table of ["software_installations", "software_installation_revisions"]) { + const columns = await q.describeTable(table); + + if (!("licenseResourceId" in columns)) { + await q.addColumn(table, "licenseResourceId", softwareSnapshot.columns.licenseResourceId); + } + } const indexes = await database.query<{ name: string }>( "SELECT DISTINCT INDEX_NAME AS name FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='software_installation_revisions'", { type: QueryTypes.SELECT } diff --git a/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts b/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts index 5f5861d..6ac5b56 100644 --- a/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts +++ b/modules/companies/src/api/infrastructure/persistence/software-installation-persistence.mapper.ts @@ -17,6 +17,7 @@ export type SoftwareRow = { companyId: string; assetId: string; resourceId: string; + licenseResourceId: string | null; currentKey: string | null; }; diff --git a/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts b/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts index 1ef6f56..b67ba55 100644 --- a/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts +++ b/modules/companies/src/api/infrastructure/persistence/software-installation-snapshot.ts @@ -19,6 +19,12 @@ export const softwareSnapshot = { references: { model: "resources", key: "id" }, onDelete: "RESTRICT", }, + licenseResourceId: { + type: DataTypes.UUID, + allowNull: true, + references: { model: "resources", key: "id" }, + onDelete: "RESTRICT", + }, version: { type: DataTypes.STRING(100), allowNull: true }, state: { type: DataTypes.STRING(20), allowNull: false }, notes: { type: DataTypes.TEXT, allowNull: true }, diff --git a/modules/companies/src/common/locales/en.json b/modules/companies/src/common/locales/en.json index 757e6fc..7deb29f 100644 --- a/modules/companies/src/common/locales/en.json +++ b/modules/companies/src/common/locales/en.json @@ -951,14 +951,14 @@ "help": "The installation belongs to the computer. Profile applications describe each person’s requirements and availability. The factory operating system remains on the asset. Programs are not installed and licenses are not consumed automatically.", "noComputer": "Designate the workstation main computer first.", "empty": "No installations recorded.", - "review": "Needs review", + "review": "Review: removal pending, computer or software unavailable, or linked license inactive or expired.", "loadError": "Unable to load installations and resources.", "errors": { "INVALID_REQUEST": "Check the installation details.", "COMPANY_NOT_FOUND": "Company not found.", "SOFTWARE_CONFIGURATION_NOT_FOUND": "Installation not found.", - "SOFTWARE_REFERENCE_NOT_FOUND": "The computer or resource does not belong to this company, or the resource is not software.", - "SOFTWARE_REFERENCE_UNAVAILABLE": "The computer is retired or the resource is inactive.", + "SOFTWARE_REFERENCE_NOT_FOUND": "The computer, software or license does not belong to this company, or the resource type is invalid.", + "SOFTWARE_REFERENCE_UNAVAILABLE": "The computer is retired, the software is inactive, or the license is inactive or expired.", "SOFTWARE_ALREADY_OPEN": "An open installation of this software already exists on this computer.", "SOFTWARE_TARGET_IMMUTABLE": "The computer and resource cannot change.", "INVALID_SOFTWARE_TRANSITION": "This state transition is not allowed.", @@ -966,7 +966,12 @@ }, "examples": "Examples: Windows 11 Pro 24H2, Thunderbird 140 or Microsoft 365 Apps.", "showClosed": "Include removed and never configured installations", - "newOs": "New operating system" + "newOs": "New operating system", + "license": "Linked license (optional)", + "licenseColumn": "License", + "noLicense": "No linked license", + "licenseHelp": "Informational reference to a license belonging to this company. It does not reserve seats or confirm coverage for this product or device. Leave it empty for free software or when the license is not yet known.", + "licenseUnavailable": "Unavailable" } }, "access": { diff --git a/modules/companies/src/common/locales/es.json b/modules/companies/src/common/locales/es.json index c7648b5..16efbcc 100644 --- a/modules/companies/src/common/locales/es.json +++ b/modules/companies/src/common/locales/es.json @@ -951,14 +951,14 @@ "help": "La instalación pertenece al ordenador. Las aplicaciones de cada perfil documentan las necesidades y disponibilidad de cada persona. El sistema operativo de fábrica permanece en el activo. No se instalan programas ni se consumen licencias automáticamente.", "noComputer": "Designa primero el ordenador principal del puesto.", "empty": "No hay instalaciones registradas.", - "review": "Requiere revisión", + "review": "Revisar: retirada pendiente, equipo o software no disponibles, o licencia vinculada inactiva o caducada.", "loadError": "No se pudieron consultar las instalaciones y los recursos.", "errors": { "INVALID_REQUEST": "Revisa los datos de la instalación.", "COMPANY_NOT_FOUND": "La compañía no existe.", "SOFTWARE_CONFIGURATION_NOT_FOUND": "La instalación no existe.", - "SOFTWARE_REFERENCE_NOT_FOUND": "El ordenador o el recurso no pertenecen a esta compañía, o el recurso no es software.", - "SOFTWARE_REFERENCE_UNAVAILABLE": "El ordenador está retirado o el recurso está inactivo.", + "SOFTWARE_REFERENCE_NOT_FOUND": "El ordenador, software o licencia no pertenecen a esta compañía, o el tipo de recurso no es válido.", + "SOFTWARE_REFERENCE_UNAVAILABLE": "El ordenador está retirado, el software está inactivo o la licencia está inactiva o caducada.", "SOFTWARE_ALREADY_OPEN": "Ya hay una instalación abierta de este software en el ordenador.", "SOFTWARE_TARGET_IMMUTABLE": "El ordenador y el recurso no pueden cambiar.", "INVALID_SOFTWARE_TRANSITION": "Este cambio de estado no está permitido.", @@ -966,7 +966,12 @@ }, "examples": "Ejemplos: Windows 11 Pro 24H2, Thunderbird 140 o Microsoft 365 Apps.", "showClosed": "Mostrar también las retiradas y nunca configuradas", - "newOs": "Nuevo sistema operativo" + "newOs": "Nuevo sistema operativo", + "license": "Licencia vinculada (opcional)", + "licenseColumn": "Licencia", + "noLicense": "Sin licencia vinculada", + "licenseHelp": "Referencia informativa a una licencia de esta compañía. No reserva plazas ni confirma que la licencia cubra este producto o equipo. Puedes dejarla vacía para software gratuito o si aún no se conoce.", + "licenseUnavailable": "No disponible" } }, "access": { diff --git a/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx b/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx index e4dc3c1..5e4c0ac 100644 --- a/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx +++ b/modules/companies/src/web/software-installations/history/ui/software-history-dialog.tsx @@ -1,3 +1,4 @@ +import { BrowserLink } from "@metis/core/client"; import { FormDialog } from "@repo/rdx-ui/components/form-dialog"; import { useTranslation } from "react-i18next"; @@ -6,9 +7,11 @@ import type { SoftwareInstallationDTO } from "../../../../common"; export function SoftwareHistoryDialog({ configuration, onClose, + resourceName, }: { onClose: () => void; configuration: SoftwareInstallationDTO; + resourceName: (id: string) => string; }) { const { t, i18n } = useTranslation("companies"); @@ -38,6 +41,18 @@ export function SoftwareHistoryDialog({ {revision.values.version && (

{t("applications.versionValue", { version: revision.values.version })}

)} +

+ {t("software.licenseColumn")}:{" "} + {revision.values.licenseResourceId ? ( + + {resourceName(revision.values.licenseResourceId)} + + ) : ( + "—" + )} +

{revision.values.notes && (

{revision.values.notes}

)} diff --git a/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx b/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx index 451d86e..ebd1ab4 100644 --- a/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx +++ b/modules/companies/src/web/software-installations/list/ui/software-installations-panel.tsx @@ -62,6 +62,9 @@ export function SoftwareInstallationsPanel({ (template) => template.id === item?.values.templateId ); + if (template?.builtinKey === "template:software_license") { + return "license"; + } if (template?.builtinKey === "template:os") { return "os"; } @@ -97,6 +100,28 @@ export function SoftwareInstallationsPanel({ label: `${item.values.name} · ${t(`software.${usage(item.id) === "os" ? "os" : "application"}`)}`, })); + const expiryFieldId = catalog.data?.items.find((item) => item.builtinKey === "field:expires")?.id; + const today = new Date().toISOString().slice(0, 10); + const licenseOptions = (resources.data?.resources ?? []) + .filter( + (item) => + usage(item.id) === "license" && + (item.values.active || item.id === edited?.values.licenseResourceId) + ) + .map((item) => { + const expiresOn = expiryFieldId ? item.values.characteristics[expiryFieldId] : undefined; + const unavailable = + !item.values.active || (typeof expiresOn === "string" && expiresOn < today); + + return { + value: item.id, + label: unavailable + ? `${item.values.name} · ${t("software.licenseUnavailable")}` + : item.values.name, + disabled: unavailable, + }; + }); + return (
item.values.version, render: (item) => item.values.version || "—", }, + { + id: "license", + label: t("software.licenseColumn"), + value: (item) => + item.values.licenseResourceId ? name(item.values.licenseResourceId) : "", + render: (item) => + item.values.licenseResourceId ? ( + + {name(item.values.licenseResourceId)} + + ) : ( + "—" + ), + }, { id: "state", label: t("status"), @@ -229,13 +270,18 @@ export function SoftwareInstallationsPanel({ assetId={assetId} companyId={companyId} installation={edited} + licenseOptions={licenseOptions} onSaved={() => setEditor(undefined)} options={options} /> )} {viewed && ( - setView(undefined)} /> + setView(undefined)} + resourceName={name} + /> )} `0199b200-0000-7000-8000-${String(n).padStart(12, "0") const companyId = id(1); const assetId = id(2); -const values = { assetId, resourceId: id(3), version: "24H2", state: "configured", notes: null }; +const values = { + assetId, + resourceId: id(3), + licenseResourceId: id(8), + version: "24H2", + state: "configured", + notes: null, +}; const item = SoftwareInstallationSchema.parse({ id: id(4), companyId, @@ -54,10 +61,20 @@ async function render(language: string, node: ReactNode) { ], }); client.setQueryData(inventoryKeys.company(companyId), { - resources: [{ id: id(3), values: { name: "Windows 11 Pro", active: true, templateId: id(7) } }], + resources: [ + { id: id(3), values: { name: "Windows 11 Pro", active: true, templateId: id(7) } }, + { id: id(8), values: { name: "Licencia Windows", active: true, templateId: id(9) } }, + ], }); client.setQueryData(catalogKeys.scope(companyId), { - items: [{ id: id(7), builtinKey: "template:os", values: { kind: "template", usage: "os" } }], + items: [ + { id: id(7), builtinKey: "template:os", values: { kind: "template", usage: "os" } }, + { + id: id(9), + builtinKey: "template:software_license", + values: { kind: "template", usage: "license" }, + }, + ], }); try { return renderToStaticMarkup( @@ -79,6 +96,8 @@ test("software del puesto es/en: SO actual, versión, enlaces y separación por assert.ok(html.includes("Windows 11 Pro")); assert.ok(html.includes("24H2")); + assert.ok(html.includes("Licencia Windows")); + assert.ok(html.includes(`href="/companies/${companyId}/resources/${id(8)}"`)); assert.ok(html.includes(language === "es" ? "Sistema operativo" : "Operating system")); assert.ok(html.includes(`href="/companies/${companyId}/resources/${id(3)}"`)); assert.equal(html.includes("OTHER-COMPUTER"), false); @@ -100,6 +119,7 @@ test("formulario software: recurso fijo en edición, estados permitidos, ayuda y assetId={assetId} companyId={companyId} installation={item} + licenseOptions={[{ value: id(8), label: "Licencia Windows" }]} onSaved={() => undefined} options={[{ value: id(3), label: "Windows 11 Pro" }]} /> @@ -107,6 +127,9 @@ test("formulario software: recurso fijo en edición, estados permitidos, ayuda y assert.ok(html.includes("La instalación pertenece al ordenador")); assert.ok(html.includes("Guardar")); + assert.ok(html.includes("Licencia vinculada (opcional)")); + assert.ok(html.includes("No reserva plazas")); + assert.equal(html.includes("software.licenseHelp"), false); assert.equal(html.includes("software.examples"), false); assert.equal(html.includes('name="profileId"'), false); assert.equal(html.includes('name="personId"'), false); diff --git a/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx b/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx index 4e216fb..85ea603 100644 --- a/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx +++ b/modules/companies/src/web/software-installations/shared/ui/software-installation-form.tsx @@ -21,12 +21,14 @@ export function SoftwareInstallationForm({ assetId, installation, options, + licenseOptions, onSaved, }: { companyId: string; assetId: string; installation: SoftwareInstallationDTO | undefined; options: readonly { value: string; label: string }[]; + licenseOptions: readonly { value: string; label: string; disabled?: boolean }[]; onSaved: () => void; }) { const { t } = useTranslation("companies"); @@ -38,6 +40,7 @@ export function SoftwareInstallationForm({ defaultValues: installation?.values ?? { assetId, resourceId: "", + licenseResourceId: null, version: null, state: "pending", notes: null, @@ -110,6 +113,21 @@ export function SoftwareInstallationForm({ /> +
+ + form.setValue("licenseResourceId", value || null)} + options={licenseOptions} + placeholder={t("software.noLicense")} + value={form.watch("licenseResourceId") ?? ""} + /> +

+ {t("software.licenseHelp")} +

+