- Simplified asset assignment validation in SequelizeAssetRepository. - Updated asset module to include legacy asset assignment migration. - Enhanced inventory assignment logic to better handle asset-person relationships. - Improved localization strings for asset guidance and scope. - Removed person assignment display from asset detail view. - Streamlined inventory page by removing person-related filters and displays. - Adjusted asset form to eliminate person assignment input. - Updated tests to reflect changes in asset and person handling. - Added phone link component for better phone number formatting and linking. - Enhanced person details view to include current workstations and improved layout. - Refactored person avatar component to support children and improved rendering.
210 lines
7.1 KiB
TypeScript
210 lines
7.1 KiB
TypeScript
import { Name, UniqueID } from "@repo/rdx-ddd";
|
|
import { Maybe, Result } from "@repo/rdx-utils";
|
|
import { z } from "zod/v4";
|
|
|
|
import { AssetCatalogError } from "../asset-definition";
|
|
import type { AssetCharacteristic, AssetInput, AssetSpecificationData } from "../asset-values";
|
|
import { AssetInputSchema, AssetSpecificationSchema } from "../asset-values";
|
|
import {
|
|
freezeTemplateSections,
|
|
sameTemplateSections,
|
|
validTemplateSections,
|
|
} from "../template-sections";
|
|
|
|
export class AssetSpecification {
|
|
private constructor(
|
|
private readonly data: AssetSpecificationData,
|
|
public readonly templateId: UniqueID,
|
|
public readonly name: Name
|
|
) {
|
|
Object.freeze(this);
|
|
}
|
|
toPrimitive() {
|
|
return this.data;
|
|
}
|
|
static create(input: AssetSpecificationData): Result<AssetSpecification, AssetCatalogError> {
|
|
const parsed = AssetSpecificationSchema.safeParse(input);
|
|
if (
|
|
!(parsed.success && validTemplateSections(input.sections ?? [], input.fields)) ||
|
|
new Set(input.fields.map((field) => field.fieldId.toLowerCase())).size !== input.fields.length
|
|
) {
|
|
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
|
}
|
|
const template = UniqueID.create(input.templateId);
|
|
const name = Name.create(input.templateName, 100);
|
|
if (!(template.isSuccess && name.isSuccess)) {
|
|
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
|
}
|
|
const frozenFields = input.fields.map((field) => {
|
|
const choices = [...field.choices];
|
|
Object.freeze(choices);
|
|
return Object.freeze({
|
|
...field,
|
|
fieldId: field.fieldId.toLowerCase(),
|
|
choices,
|
|
...(field.sectionId ? { sectionId: field.sectionId.toLowerCase() } : {}),
|
|
});
|
|
});
|
|
Object.freeze(frozenFields);
|
|
return Result.ok(
|
|
new AssetSpecification(
|
|
Object.freeze({
|
|
templateId: template.data.toPrimitive(),
|
|
templateName: name.data.toPrimitive(),
|
|
fields: frozenFields,
|
|
...(input.sections ? { sections: freezeTemplateSections(input.sections) } : {}),
|
|
}),
|
|
template.data,
|
|
name.data
|
|
)
|
|
);
|
|
}
|
|
equals(other: AssetSpecification | undefined) {
|
|
if (!(other instanceof AssetSpecification)) {
|
|
return false;
|
|
}
|
|
const first = this.toPrimitive();
|
|
const second = other.toPrimitive();
|
|
return (
|
|
first.templateId === second.templateId &&
|
|
first.templateName === second.templateName &&
|
|
sameTemplateSections(first.sections ?? [], second.sections ?? []) &&
|
|
first.fields.length === second.fields.length &&
|
|
first.fields.every((field, index) => {
|
|
const candidate = second.fields[index];
|
|
return (
|
|
candidate !== undefined &&
|
|
field.fieldId === candidate.fieldId &&
|
|
field.name === candidate.name &&
|
|
field.type === candidate.type &&
|
|
field.unit === candidate.unit &&
|
|
field.required === candidate.required &&
|
|
field.sectionId === candidate.sectionId &&
|
|
field.choices.length === candidate.choices.length &&
|
|
field.choices.every((choice, position) => choice === candidate.choices[position])
|
|
);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
function validCharacteristic(field: AssetCharacteristic, value: unknown) {
|
|
switch (field.type) {
|
|
case "text":
|
|
return typeof value === "string";
|
|
case "number":
|
|
return typeof value === "number" && Number.isFinite(value);
|
|
case "boolean":
|
|
return typeof value === "boolean";
|
|
case "choice":
|
|
return typeof value === "string" && field.choices.includes(value);
|
|
case "date":
|
|
return z.iso.date().safeParse(value).success;
|
|
}
|
|
}
|
|
export class AssetValues {
|
|
private constructor(
|
|
private readonly data: AssetInput,
|
|
public readonly name: Name,
|
|
public readonly siteId: Maybe<UniqueID>,
|
|
public readonly brandId: Maybe<UniqueID>
|
|
) {
|
|
Object.freeze(this);
|
|
}
|
|
toPrimitive() {
|
|
return this.data;
|
|
}
|
|
static create(
|
|
input: AssetInput,
|
|
specification: AssetSpecification
|
|
): Result<AssetValues, AssetCatalogError> {
|
|
const parsed = AssetInputSchema.safeParse(input);
|
|
const name = Name.create(input.name, 100);
|
|
if (
|
|
!(parsed.success && name.isSuccess) ||
|
|
input.templateId.toLowerCase() !== specification.toPrimitive().templateId
|
|
) {
|
|
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
|
}
|
|
const data = parsed.data;
|
|
const site = optionalIdentity(data.siteId);
|
|
const parsedCharacteristics = normalizeCharacteristics(data.characteristics, specification);
|
|
if (!parsedCharacteristics.isSuccess) {
|
|
return parsedCharacteristics;
|
|
}
|
|
const characteristics = parsedCharacteristics.data;
|
|
return Result.ok(
|
|
new AssetValues(
|
|
Object.freeze({
|
|
...data,
|
|
name: name.data.toPrimitive(),
|
|
templateId: data.templateId.toLowerCase(),
|
|
siteId: data.siteId?.toLowerCase() ?? null,
|
|
...(data.brandId === undefined ? {} : { brandId: data.brandId?.toLowerCase() ?? null }),
|
|
notes: data.notes.trim(),
|
|
characteristics: Object.freeze(characteristics),
|
|
}),
|
|
name.data,
|
|
site,
|
|
optionalIdentity(data.brandId ?? null)
|
|
)
|
|
);
|
|
}
|
|
equals(other: AssetValues | undefined) {
|
|
if (!(other instanceof AssetValues)) {
|
|
return false;
|
|
}
|
|
const first = this.toPrimitive();
|
|
const second = other.toPrimitive();
|
|
return (
|
|
first.name === second.name &&
|
|
first.templateId === second.templateId &&
|
|
first.siteId === second.siteId &&
|
|
(first.brandId ?? null) === (second.brandId ?? null) &&
|
|
first.status === second.status &&
|
|
first.notes === second.notes &&
|
|
Object.keys(first.characteristics).length === Object.keys(second.characteristics).length &&
|
|
Object.entries(first.characteristics).every(
|
|
([key, value]) => value === second.characteristics[key]
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
function normalizeCharacteristics(
|
|
input: AssetInput["characteristics"],
|
|
specification: AssetSpecification
|
|
): Result<AssetInput["characteristics"], AssetCatalogError> {
|
|
const characteristics: AssetInput["characteristics"] = {};
|
|
for (const [key, raw] of Object.entries(input)) {
|
|
const field = specification
|
|
.toPrimitive()
|
|
.fields.find((item) => item.fieldId === key.toLowerCase());
|
|
const value = typeof raw === "string" ? raw.trim() : raw;
|
|
if (!field || key.toLowerCase() in characteristics || !validCharacteristic(field, value)) {
|
|
return Result.fail(new AssetCatalogError("INVALID_CHARACTERISTICS"));
|
|
}
|
|
if (value !== "") {
|
|
characteristics[key.toLowerCase()] = value;
|
|
}
|
|
}
|
|
if (
|
|
specification
|
|
.toPrimitive()
|
|
.fields.some((field) => field.required && !(field.fieldId in characteristics))
|
|
) {
|
|
return Result.fail(new AssetCatalogError("REQUIRED_CHARACTERISTICS"));
|
|
}
|
|
return Result.ok(characteristics);
|
|
}
|
|
|
|
function optionalIdentity(value: string | null): Maybe<UniqueID> {
|
|
if (value === null) {
|
|
return Maybe.none();
|
|
}
|
|
const parsed = UniqueID.create(value);
|
|
if (!parsed.isSuccess) {
|
|
throw new Error("Referencia validada inconsistente.");
|
|
}
|
|
return Maybe.some(parsed.data);
|
|
}
|