Reorganizar el codigo de cliente por responsabilidades.

This commit is contained in:
David Arranz 2026-10-09 00:27:19 +02:00
parent f5dd8fdf31
commit 9733f550a5
665 changed files with 8531 additions and 5381 deletions

12
TODO.md
View File

@ -427,4 +427,14 @@ El arbol completo para este ejemplo de "facturas" quedaría así:
└── 📄 index.ts
```
Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesites para hacer el cambio bien a la primera.
Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesites para hacer el cambio bien a la primera.
- En la lista de marcas, quiero poder incluir el logotipo. Como son marcas conocidas, rellena ese nuevo campo buscando en internet el logo correspondiente en formato SVG, sin fondo.
- Quiero que en el navegador web del cliente se muestren rutas para seccion. Por ejemplo: http://192.168.0.2:5173/dashboard, http://192.168.0.2:5173/tickets, http://192.168.0.2:5173/companies/, http://192.168.0.2:5173/companies/xxxx, etc. Analiza qué estructura de ruta es la más adecuada las pantallas de la aplicación y muestrala antes de realizar ningún cambio. Intenta encontar ambiguedades o errores también. Como criterio, guíate por la lógica o por lo más común que se hace en este caso.

View File

@ -0,0 +1,85 @@
# Auditoría de separación lógica del cliente
## Alcance y referencia
Revisión de `modules/core/src/web`, `modules/identity/src/web`, `modules/assets/src/web`,
`modules/resources/src/web` y `modules/companies/src/web`, usando `AGENTS.md`, la documentación
normativa y el código de `customer-invoices.zip` como referencia de separación de responsabilidades.
Se excluyen los cambios en `apps/web/src`, vendor, backend, schemas HTTP y persistencia.
La referencia aporta controllers, adapters y acceso a API por operaciones. No se incorporan
sus dependencias, factories, tipos genéricos, memoización o servicios ajenos al proyecto.
## Áreas revisadas
- Core: health y home; la consulta ya tiene API y claves separadas. No se añade un controller
que únicamente envuelva esa consulta.
- Identity: sesión, autenticación, logout, contraseñas, gestión de usuarios y workspace.
- Assets: inventario, detalle, características, relaciones, puestos, marcas, catálogos y soporte.
- Resources: catálogos, plantillas, editor, valores iniciales e integraciones de recursos.
- Companies: compañías, logos, personas, departamentos, sedes, asignaciones, accesos,
dashboard y workspace; correo, usos de buzón, perfiles, carpetas, aplicaciones,
certificados e impresoras; tickets, intervenciones, plantillas, procesos, teletrabajo y remotos.
La inspección combina inventario y análisis sintáctico del código, revisión de dependencias,
lectura de las responsabilidades y diffs afectados y comprobaciones por módulos.
## Incumplimientos y cambios
1. Formularios y páginas coordinaban consultas, mutaciones, invalidación, defaults y grandes
bloques visuales en el mismo componente. Se extraen 78 controllers con responsabilidades
concretas, conservando RHF y TanStack Query como fuentes de verdad.
2. Cálculos reutilizados se exportaban desde `.tsx`, incluso para usarlos desde coordinación.
Se separan filtros, integración, destino remoto, navegación, fechas y progreso de pasos.
3. Schemas locales y contratos de formularios se mezclaban con la presentación. Se colocan
en `schemas` y `types`, sin duplicar DTOs HTTP. Los adapters separan conversiones reales
de personas, sedes e instrucciones de procesos.
4. Clientes compartidos agrupaban varias entidades. Se trasladan 31 operaciones de identidad,
inventario, soporte, compañías y procesos a sus ámbitos. Accesos y buzones distinguen
consulta y guardado. Los clientes pequeños que ya tienen un ámbito cohesivo se conservan.
5. Los callbacks de altas anidadas exponían refs de montaje a UI. Su coordinación y protección
ante desmontaje quedan privadas de los controllers.
6. Los índices se ajustan a sus consumidores y a la responsabilidad que exportan, evitando
compatibilidad artificial desde UI y ciclos por composición de API.
Los componentes simples, helpers visuales privados y hooks existentes que ya resuelven
su responsabilidad permanecen próximos a su consumidor. No se fragmenta JSX trivial,
se duplica estado remoto ni se añaden dependencias o nuevas capas de negocio.
Se preservan el comportamiento y las composiciones públicas requeridas por la aplicación
y los demás módulos, incluidas identidad, inventario y catálogos. No cambian endpoints,
schemas, datos persistidos, migraciones ni textos de interfaz.
## Comprobaciones
- Typechecks y pruebas dirigidas de los módulos y de la aplicación, sin modificar esta última.
- Doce pruebas nuevas incluidas en los scripts de paquete: sesión y autenticación, rutas,
métodos HTTP, CSRF, errores seguros, respuestas inválidas, accesos, usos de buzón,
adapters, integración y límites de logos.
- Comparación sintáctica de los cuerpos y parámetros de las 31 operaciones trasladadas,
normalizando únicamente el nombre del transporte compartido.
- Análisis de dependencias relativas de los cinco módulos, sin ciclos detectados.
- Comparación de elementos JSX y atributos estáticos de los 252 componentes inventariados
frente a Git, sin cambios accidentales en estructura, clases o atributos estáticos.
Resultados globales del cierre de esta revisión:
| Comando | Resultado |
| --- | --- |
| `pnpm typecheck` | Correcto |
| `pnpm lint` | Correcto, sin avisos |
| `pnpm test` | Correcto: 236 pruebas, ningún fallo |
| `pnpm check` | Correcto; se corrigió el orden de imports detectado en core |
| `pnpm build` | Correcto, con aviso de bundle web superior a 500 kB |
Las pruebas de UI existentes comprueban renderizado, estados, traducciones y contratos
observables. No sustituyen una prueba manual en navegador de foco, teclado y apertura/cierre
de todas las combinaciones de modales; esta revisión no incorpora un runner de navegador.
## Pendientes y trabajo ajeno
No se ha necesitado una decisión arquitectónica para esta separación. Los cambios previos
del usuario en `TODO.md` y el ZIP de referencia se conservan. Las nuevas funcionalidades
anotadas allí, como búsqueda de logos SVG y routing, no forman parte de esta auditoría.
El aviso de tamaño del bundle no impide el build. Su posible división requiere un trabajo
específico de carga de pantallas y no se introduce como efecto secundario de esta separación.

View File

@ -44,6 +44,15 @@ tickets/
index.ts
index.ts
list/
controllers/
use-tickets-page-controller.ts
index.ts
helpers/
tickets-filtering.ts
index.ts
types/
tickets-page-props.ts
index.ts
ui/
tickets-page.tsx
index.ts
@ -52,6 +61,15 @@ tickets/
api/
ticket-api.ts
index.ts
controllers/
use-ticket-form-controller.ts
index.ts
helpers/
ticket-target-options.ts
index.ts
types/
ticket-form-props.ts
index.ts
ui/
ticket-form.tsx
ticket-editor-dialog.tsx
@ -125,9 +143,44 @@ se abra desde una intervención; el consumidor no determina la identidad del com
Situar el acceso HTTP en el ámbito más específico que corresponda a sus consumidores:
`<entity>/<operation>/api`, `<entity>/shared/api` o `web/shared/api`. Los clientes existentes
que abarcan varias entidades, como `identity/shared/api/identity-api.ts` y
`assets/shared/api/inventory-api.ts`, pueden permanecer compartidos. No dividir un cliente
ni alterar sus firmas, queries o contratos únicamente para reproducir el árbol de ejemplo.
que abarcan varias entidades se separan por sus operaciones reales. Por ejemplo,
`users/create/api/create-user.api.ts`, `people/shared/api/save-person.api.ts` y
`process-templates/list/api/list-process-templates.api.ts`. El transporte, errores y claves
compartidos no se duplican en cada operación. Las composiciones públicas existentes de
identidad e inventario conservan sus consumidores externos, pero los controllers del propio
módulo utilizan las funciones de su operación. No dividir clientes pequeños y cohesivos
ni alterar firmas, queries o contratos únicamente para reproducir el árbol de ejemplo.
## Separación lógica de la operación
El ejemplo de `customer-invoices` orienta la separación de responsabilidades, no introduce
sus dependencias, servicios ni arquitectura de persistencia en Metis Desk:
- `ui`: JSX, traducciones de presentación, accesibilidad y adaptación de eventos del DOM.
Los modales y formularios significativos conservan su componente independiente.
- `controllers`: hooks `use-…-controller` para coordinación de formularios, queries,
mutaciones, invalidación, altas relacionadas y callbacks. Los detalles de ciclo de vida
utilizados para evitar callbacks después del desmontaje quedan privados del controller.
- `adapters`: transformación de valores de formulario a entradas de la operación cuando
exista una conversión real, como vacíos a `null` o líneas a instrucciones. No envolver
objetos idénticos ni duplicar la validación de los schemas.
- `helpers`: selección, filtrado y cálculos sin acceso a React, transporte o persistencia.
Mantener nombres que identifiquen su propósito y su entidad.
- `schemas` y `types`: validación exclusiva del formulario y contratos locales inferidos
de su schema. Los contratos HTTP siguen perteneciendo a `common`.
- `api` y `queries`: transporte validado y claves de caché separadas de la presentación.
Un controller no es una capa nueva de negocio ni un repositorio: debe conservar la operación
existente, no ocultar efectos tras nombres genéricos. No necesita `useMemo` o `useCallback`
por defecto. Formularios pequeños y consultas simples pueden permanecer en su componente
cuando no mezclen responsabilidades significativas; un hook existente que ya encapsule
la consulta no requiere un segundo controller que solo lo envuelva.
Los controllers, adapters y helpers no importan implementaciones de UI para acceder a
cálculos o constantes. Utilizar imports directos entre APIs cuando pasar por un índice
agregado reintroduzca la composición pública en su propio transporte. Los índices exponen
solo contratos de su responsabilidad utilizados por otras unidades, no reexports internos
de compatibilidad desde el antiguo componente.
## Índices y dependencias
@ -180,8 +233,11 @@ cuando utilice una enumeración explícita.
La reorganización inicial trasladó ficheros y ajustó sus imports, sin cambiar comportamiento
ni contratos. No extrajo lógica nueva a controllers, hooks, schemas o adapters; esa auditoría
se aplazó por decisión del usuario. Esa limitación describe la migración, no exime al código
nuevo de las reglas de separación de responsabilidades de `AGENTS.md`.
se aplazó por decisión del usuario. La auditoría posterior separó la lógica de formularios,
listados y coordinación en los cinco módulos, excluyendo `apps/web/src` y vendor. Su alcance,
criterios y comprobaciones se registran en [la auditoría del cliente](auditoria-de-separacion-logica-del-cliente.md).
La limitación de la migración inicial no exime al código nuevo de las reglas de separación
de responsabilidades de `AGENTS.md`.
Para futuros cambios, consultar también [componentes de interfaz](componentes-de-interfaz.md),
[inventario de modales](inventario-de-modales.md), [patrón de listados](patron-de-listados-de-entidades.md)

View File

@ -0,0 +1,13 @@
import type { RelationshipInputDTO } from "../../../../common/relationships";
import { RelationshipResponseSchema } from "../../../../common/relationships";
import { inventoryRequest } from "../../../shared/api/inventory-request";
export async function createAssetRelationship(
companyId: string,
values: RelationshipInputDTO,
token: string
) {
return RelationshipResponseSchema.parse(
await inventoryRequest(`${companyId}/relationships`, { values }, token)
);
}

View File

@ -0,0 +1 @@
export { createAssetRelationship } from "./create-asset-relationship.api";

View File

@ -0,0 +1,8 @@
import { RelationshipResponseSchema } from "../../../../common/relationships";
import { inventoryRequest } from "../../../shared/api/inventory-request";
export async function endAssetRelationship(companyId: string, id: string, token: string) {
return RelationshipResponseSchema.parse(
await inventoryRequest(`${companyId}/relationships/${id}/end`, {}, token)
);
}

View File

@ -0,0 +1 @@
export { endAssetRelationship } from "./end-asset-relationship.api";

View File

@ -0,0 +1 @@
export { useEndRelationshipDialogController } from "./use-end-relationship-dialog-controller";

View File

@ -0,0 +1,18 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { inventoryKeys } from "../../../shared/queries";
import { endAssetRelationship } from "../api";
import type { EndRelationshipDialogProps } from "../types";
export function useEndRelationshipDialogController({
relationship,
companyId,
csrfToken,
}: EndRelationshipDialogProps) {
const client = useQueryClient();
const mutation = useMutation({
mutationFn: () => endAssetRelationship(companyId, relationship.id, csrfToken),
onSuccess: () => client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }),
});
return { mutation };
}

View File

@ -0,0 +1,10 @@
import type { RelationshipDTO } from "../../../../common";
export type EndRelationshipDialogProps = {
relationship: RelationshipDTO;
companyId: string;
csrfToken: string;
sourceName: string;
targetName: string;
onClose: () => void;
};

View File

@ -0,0 +1 @@
export type { EndRelationshipDialogProps } from "./end-relationship-dialog-props";

View File

@ -1,32 +1,15 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { RelationshipDTO } from "../../../../common";
import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared";
import { InventoryFeedback } from "../../../shared";
import { useEndRelationshipDialogController } from "../controllers";
import type { EndRelationshipDialogProps } from "../types";
export function EndRelationshipDialog({
relationship,
companyId,
csrfToken,
sourceName,
targetName,
onClose,
}: {
relationship: RelationshipDTO;
companyId: string;
csrfToken: string;
sourceName: string;
targetName: string;
onClose: () => void;
}) {
export function EndRelationshipDialog(props: EndRelationshipDialogProps) {
const { relationship, sourceName, targetName, onClose } = props;
const { mutation } = useEndRelationshipDialogController(props);
const { t } = useTranslation("assets");
const client = useQueryClient();
const mutation = useMutation({
mutationFn: () => inventoryApi.endRelationship(companyId, relationship.id, csrfToken),
onSuccess: () => client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }),
});
return (
<FormDialog
cancelLabel={t("cancel")}

View File

@ -0,0 +1 @@
export { useAssetRelationshipsController } from "./use-asset-relationships-controller";

View File

@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { getAssetInventory } from "../../../assets/list/api";
import { inventoryKeys } from "../../../shared/queries";
import type { AssetRelationshipsProps } from "../types";
export function useAssetRelationshipsController({ companyId, assetId }: AssetRelationshipsProps) {
const { i18n } = useTranslation("assets");
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});
const [adding, setAdding] = useState(false);
const [endingId, setEndingId] = useState<string>();
const ending = query.data?.relationships.find((relationship) => relationship.id === endingId);
const links = (query.data?.relationships ?? []).filter(
(link) => link.values.sourceId === assetId || link.values.targetId === assetId
);
const active = links.filter((link) => !link.endedAt);
const ended = links.filter((link) => link.endedAt);
const dateOf = (date: string) =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(date)
);
return { query, adding, setAdding, setEndingId, ending, active, ended, dateOf };
}

View File

@ -0,0 +1,5 @@
export type AssetRelationshipsProps = {
companyId: string;
csrfToken: string;
assetId: string;
};

View File

@ -0,0 +1 @@
export type { AssetRelationshipsProps } from "./asset-relationships-props";

View File

@ -1,41 +1,19 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { RelationshipDTO } from "../../../../common";
import { inventoryApi, inventoryKeys } from "../../../shared";
import { EndRelationshipDialog } from "../../end";
import { RelationshipEditorDialog } from "../../shared";
import { useAssetRelationshipsController } from "../controllers";
import type { AssetRelationshipsProps } from "../types";
export function AssetRelationships({
companyId,
csrfToken,
assetId,
}: {
companyId: string;
csrfToken: string;
assetId: string;
}) {
const { t, i18n } = useTranslation("assets");
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const [adding, setAdding] = useState(false);
const [endingId, setEndingId] = useState<string>();
const ending = query.data?.relationships.find((relationship) => relationship.id === endingId);
const links = (query.data?.relationships ?? []).filter(
(link) => link.values.sourceId === assetId || link.values.targetId === assetId
);
const active = links.filter((link) => !link.endedAt);
const ended = links.filter((link) => link.endedAt);
export function AssetRelationships(props: AssetRelationshipsProps) {
const { companyId, csrfToken, assetId } = props;
const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } =
useAssetRelationshipsController(props);
const { t } = useTranslation("assets");
const nameOf = (id: string) =>
query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id;
const dateOf = (date: string) =>
new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(date)
);
const render = (link: RelationshipDTO) => {
const source = link.values.sourceId === assetId;
return (

View File

@ -0,0 +1 @@
export { useRelationshipFormController } from "./use-relationship-form-controller";

View File

@ -0,0 +1,63 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { FormEvent } from "react";
import { useState } from "react";
import type { RelationshipInputDTO } from "../../../../common";
import { getAssetInventory } from "../../../assets/list/api";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries";
import { createAssetRelationship } from "../../create/api";
import type { RelationshipFormProps } from "../types";
export function useRelationshipFormController({
companyId,
csrfToken,
assetId,
onSaved,
}: RelationshipFormProps) {
const client = useQueryClient();
const [typeId, setTypeId] = useState("");
const [targetId, setTargetId] = useState("");
const [notes, setNotes] = useState("");
const [quick, setQuick] = useState(false);
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});
const mutation = useMutation({
mutationFn: (values: RelationshipInputDTO) =>
createAssetRelationship(companyId, values, csrfToken),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
onSaved();
},
});
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
};
const relationshipTypes = (catalog.data?.items ?? []).filter(
(item) => item.values.kind === "relation" && item.effectiveActive
);
const targetAssets = (inventory.data?.assets ?? []).filter((asset) => asset.id !== assetId);
return {
typeId,
setTypeId,
targetId,
setTargetId,
notes,
setNotes,
quick,
setQuick,
catalog,
inventory,
mutation,
submit,
relationshipTypes,
targetAssets,
};
}

View File

@ -0,0 +1 @@
export type { RelationshipFormProps } from "./relationship-form-props";

View File

@ -0,0 +1,6 @@
export type RelationshipFormProps = {
companyId: string;
csrfToken: string;
assetId: string;
onSaved: () => void;
};

View File

@ -4,57 +4,37 @@ import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { RelationshipInputDTO } from "../../../../common";
import { catalogApi, catalogKeys } from "../../../catalog";
import { CatalogRelationCreateDialog } from "../../../catalog-relations";
import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared";
import { InventoryFeedback } from "../../../shared";
import { useRelationshipFormController } from "../controllers";
import type { RelationshipFormProps } from "../types";
export function RelationshipForm({
companyId,
csrfToken,
assetId,
onSaved,
}: {
companyId: string;
csrfToken: string;
assetId: string;
onSaved: () => void;
}) {
export function RelationshipForm(props: RelationshipFormProps) {
const { companyId, csrfToken } = props;
const {
typeId,
setTypeId,
targetId,
setTargetId,
notes,
setNotes,
quick,
setQuick,
catalog,
inventory,
mutation,
submit,
relationshipTypes,
targetAssets,
} = useRelationshipFormController(props);
const { t } = useTranslation("assets");
const id = useId();
const client = useQueryClient();
const [typeId, setTypeId] = useState("");
const [targetId, setTargetId] = useState("");
const [notes, setNotes] = useState("");
const [quick, setQuick] = useState(false);
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const inventory = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const mutation = useMutation({
mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken),
onSuccess: async () => {
await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) });
onSaved();
},
});
return (
<>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
}}
>
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-2">
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
<SelectWithAction
@ -63,9 +43,7 @@ export function RelationshipForm({
id={`${id}-type`}
label={t("relationshipType")}
onValueChange={setTypeId}
options={(catalog.data?.items ?? [])
.filter((item) => item.values.kind === "relation" && item.effectiveActive)
.map((item) => ({ value: item.id, label: item.values.name }))}
options={relationshipTypes.map((item) => ({ value: item.id, label: item.values.name }))}
placeholder={t("chooseRelation")}
required
value={typeId}
@ -78,9 +56,7 @@ export function RelationshipForm({
id={`${id}-target`}
label={t("targetAsset")}
onValueChange={setTargetId}
options={(inventory.data?.assets ?? [])
.filter((asset) => asset.id !== assetId)
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
options={targetAssets.map((asset) => ({ value: asset.id, label: asset.values.name }))}
placeholder={t("chooseAsset")}
required
value={targetId}

View File

@ -0,0 +1 @@
export { useAssetDetailController } from "./use-asset-detail-controller";

View File

@ -0,0 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { inventoryKeys } from "../../../shared/queries";
import { getAssetInventory } from "../../list/api";
import type { AssetDetailProps } from "../types";
export function useAssetDetailController({ asset }: AssetDetailProps) {
const inventory = useQuery({
queryKey: inventoryKeys.company(asset.companyId),
queryFn: () => getAssetInventory(asset.companyId),
});
const workstations = inventory.data?.workstations.filter(
(item) =>
item.values.active &&
(item.values.assetIds.includes(asset.id) || item.values.sharedAssetIds.includes(asset.id))
);
return { workstations };
}

View File

@ -0,0 +1,11 @@
import type { ReactNode } from "react";
import type { AssetDTO } from "../../../../common/inventory";
import type { InventoryLookups } from "../../shared/types";
export type AssetDetailProps = {
asset: AssetDTO;
lookups: InventoryLookups;
compact?: boolean;
children?: ReactNode;
};

View File

@ -0,0 +1 @@
export type { AssetDetailProps } from "./asset-detail-props";

View File

@ -1,33 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../../../../common/inventory";
import { inventoryApi, inventoryKeys } from "../../../shared";
import type { InventoryLookups } from "../../shared";
export function AssetDetail({
asset,
lookups,
compact = false,
children,
}: {
asset: AssetDTO;
lookups: InventoryLookups;
compact?: boolean;
children?: ReactNode;
}) {
import { useAssetDetailController } from "../controllers";
import type { AssetDetailProps } from "../types";
export function AssetDetail(props: AssetDetailProps) {
const { asset, lookups, compact = false, children } = props;
const { workstations } = useAssetDetailController(props);
const { t, i18n } = useTranslation("assets");
const inventory = useQuery({
queryKey: inventoryKeys.company(asset.companyId),
queryFn: () => inventoryApi.get(asset.companyId),
});
const workstations = inventory.data?.workstations.filter(
(item) =>
item.values.active &&
(item.values.assetIds.includes(asset.id) || item.values.sharedAssetIds.includes(asset.id))
);
const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) =>
id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment");
const nameOf = (
id: string | null,
options: readonly {
id: string;
name: string;
}[]
) => (id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment"));
const display = (value: string | number | boolean | undefined) =>
value === undefined
? t("noValue")

View File

@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import type { AssetDTO } from "../../../../common/inventory";
import { AssetRelationships } from "../../../asset-relationships";
import { AssetSupport } from "../../../support-connections";
import type { InventoryLookups } from "../../shared";
import type { InventoryLookups } from "../../shared/types";
import { AssetDetail } from "./asset-detail";

View File

@ -1,4 +1,4 @@
export { WorkstationAssetCreateDialog } from "./create";
export { AssetInventoryPage } from "./list";
export type { InventoryLookups } from "./shared";
export { AssetForm } from "./shared";
export type { InventoryLookups } from "./shared/types";

View File

@ -0,0 +1,6 @@
import { InventoryResponseSchema } from "../../../../common/inventory";
import { inventoryRequest } from "../../../shared/api/inventory-request";
export async function getAssetInventory(companyId: string) {
return InventoryResponseSchema.parse(await inventoryRequest(companyId));
}

View File

@ -0,0 +1 @@
export { getAssetInventory } from "./get-asset-inventory.api";

View File

@ -0,0 +1 @@
export { useAssetInventoryPageController } from "./use-inventory-page-controller";

View File

@ -0,0 +1,61 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryKeys } from "../../../shared/queries";
import { getAssetInventory } from "../api";
import type { AssetInventoryPageProps } from "../types";
export function useAssetInventoryPageController({
companyId,
lookups,
initialId,
initialCreate = false,
}: AssetInventoryPageProps) {
const { t } = useTranslation("assets");
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => getAssetInventory(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [viewId, setViewId] = useState<string | undefined>();
const [selectedId, setSelectedId] = useState<string | undefined>(initialId);
const [detailOpen, setDetailOpen] = useState(true);
const assets = query.data?.assets ?? [];
const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0];
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => {
const person = lookups.people.find((person) => person.id === asset.values.personId);
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
return [
asset.values.name,
asset.specification.templateName,
person?.name ?? "",
site?.name ?? "",
t(asset.values.status),
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
});
return {
query,
catalog,
search,
setSearch,
editor,
setEditor,
setViewId,
setSelectedId,
detailOpen,
setDetailOpen,
selected,
edited,
viewed,
filtered,
};
}

View File

@ -0,0 +1 @@
export type { AssetInventoryPageProps } from "./inventory-page-props";

View File

@ -0,0 +1,9 @@
import type { InventoryLookups } from "../../shared/types";
export type AssetInventoryPageProps = {
companyId: string;
csrfToken: string;
lookups: InventoryLookups;
initialId?: string | undefined;
initialCreate?: boolean | undefined;
};

View File

@ -6,58 +6,39 @@ import {
EntityStatus,
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { CatalogError, catalogApi, catalogKeys } from "../../../catalog";
import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared";
import { CatalogError } from "../../../catalog";
import { InventoryFeedback } from "../../../shared";
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import type { InventoryLookups } from "../../shared";
import { AssetEditorDialog } from "../../shared";
import { useAssetInventoryPageController } from "../controllers";
import type { AssetInventoryPageProps } from "../types";
export function AssetInventoryPage({
companyId,
csrfToken,
lookups,
initialId,
initialCreate = false,
}: {
companyId: string;
csrfToken: string;
lookups: InventoryLookups;
initialId?: string | undefined;
initialCreate?: boolean | undefined;
}) {
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
return id === selectedId && 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"
: "";
}
export function AssetInventoryPage(props: AssetInventoryPageProps) {
const { companyId, csrfToken, lookups } = props;
const {
query,
catalog,
search,
setSearch,
editor,
setEditor,
setViewId,
setSelectedId,
detailOpen,
setDetailOpen,
selected,
edited,
viewed,
filtered,
} = useAssetInventoryPageController(props);
const { t, i18n } = useTranslation("assets");
const query = useQuery({
queryKey: inventoryKeys.company(companyId),
queryFn: () => inventoryApi.get(companyId),
});
const catalog = useQuery({
queryKey: catalogKeys.scope(companyId),
queryFn: () => catalogApi.get(companyId),
});
const [search, setSearch] = useState("");
const [editor, setEditor] = useState<string | undefined>(initialCreate ? "new" : undefined);
const [viewId, setViewId] = useState<string | undefined>();
const [selectedId, setSelectedId] = useState<string | undefined>(initialId);
const [detailOpen, setDetailOpen] = useState(true);
const assets = query.data?.assets ?? [];
const selected = assets.find((asset) => asset.id === selectedId) ?? assets[0];
const edited = assets.find((asset) => asset.id === editor);
const viewed = assets.find((asset) => asset.id === viewId);
const filtered = assets.filter((asset) => {
const person = lookups.people.find((person) => person.id === asset.values.personId);
const site = lookups.sites.find((site) => site.id === asset.values.siteId);
return [
asset.values.name,
asset.specification.templateName,
person?.name ?? "",
site?.name ?? "",
t(asset.values.status),
].some((value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
});
return (
<div
className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 inventory:grid-cols-[minmax(0,1fr)_280px] inventory:data-[detail-open=false]:grid-cols-[minmax(0,1fr)]"
@ -240,9 +221,3 @@ export function AssetInventoryPage({
</div>
);
}
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
return id === selectedId && 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"
: "";
}

View File

@ -0,0 +1 @@
export { saveAsset } from "./save-asset.api";

View File

@ -0,0 +1,9 @@
import type { AssetInput } from "../../../../common/inventory";
import { AssetResponseSchema } from "../../../../common/inventory";
import { inventoryRequest } from "../../../shared/api/inventory-request";
export async function saveAsset(companyId: string, values: AssetInput, token: string, id?: string) {
return AssetResponseSchema.parse(
await inventoryRequest(`${companyId}${id ? `/${id}/update` : ""}`, { values }, token)
);
}

View File

@ -0,0 +1 @@
export { useAssetFormController } from "./use-asset-form-controller";

View File

@ -0,0 +1,134 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import type { AssetInput } from "../../../../common/inventory";
import { AssetInputSchema } from "../../../../common/inventory";
import { inventoryKeys } from "../../../shared/queries";
import { saveAsset } from "../api";
import type { AssetFormProps } from "../types";
export function useAssetFormController({
companyId,
csrfToken,
asset,
items,
onSaved,
}: AssetFormProps) {
const client = useQueryClient();
const [creatingTemplate, setCreatingTemplate] = useState(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<AssetInput>({
resolver: zodResolver(AssetInputSchema),
defaultValues: asset?.values ?? {
name: "",
templateId: "",
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},
},
});
const values = form.watch();
const brandFieldId = items.find((item) => item.referenceCatalog === "brands")?.id;
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
const template = templates.find((item) => item.id === values.templateId);
const fields =
asset?.specification.fields ??
(template?.values.kind === "template"
? template.values.fields.flatMap((binding) => {
const field = items.find((item) => item.id === binding.fieldId);
return field?.values.kind === "field"
? [
{
fieldId: field.id,
name: field.values.name,
type: field.values.type,
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
},
]
: [];
})
: []);
const mutation = useMutation({
mutationFn: (input: AssetInput) => saveAsset(companyId, input, csrfToken, asset?.id),
onSuccess: async (response) => {
await Promise.all([
client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }),
client.invalidateQueries({ queryKey: ["remote-connections", companyId] }),
]);
if (mounted.current) {
onSaved(response.asset.id);
}
},
});
const setCharacteristic = (
fieldId: string,
value: AssetInput["characteristics"][string] | undefined
) => {
const characteristics = { ...form.getValues("characteristics") };
if (value === undefined) {
delete characteristics[fieldId];
} else {
characteristics[fieldId] = value;
}
form.setValue("characteristics", characteristics, { shouldDirty: true });
};
const submit = form.handleSubmit((input) => mutation.mutate(input));
const selectTemplate = (templateId: string) => {
form.setValue("templateId", templateId);
form.setValue("characteristics", {});
form.setValue("brandId", null);
};
const changeStatus = (value: string) => {
const parsed = AssetStatusSchema.safeParse(value);
if (parsed.success) {
form.setValue("status", parsed.data);
}
};
const templateCreated = (id: string) => {
form.setValue("templateId", id);
form.setValue("characteristics", {});
form.setValue("brandId", null);
setCreatingTemplate(false);
};
const siteCreated = (id: string) => {
if (mounted.current) {
form.setValue("siteId", id);
}
};
const personCreated = (id: string) => {
if (mounted.current) {
form.setValue("personId", id);
}
};
return {
creatingTemplate,
setCreatingTemplate,
form,
values,
brandFieldId,
templates,
fields,
mutation,
setCharacteristic,
submit,
selectTemplate,
changeStatus,
templateCreated,
siteCreated,
personCreated,
};
}
import { AssetStatusSchema } from "../../../../common/inventory";

View File

@ -1,2 +1,2 @@
export type { InventoryLookups } from "./ui";
export type { InventoryLookups } from "./types";
export { AssetEditorDialog, AssetForm } from "./ui";

View File

@ -14,10 +14,11 @@ import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
import type { RelationshipDTO } from "../../../common";
import { AssetRelationships } from "../../asset-relationships";
import { catalogKeys } from "../../catalog";
import { catalogKeys } from "../../catalog/shared/api";
import { CatalogRelationForm } from "../../catalog-relations";
import { CatalogTemplateForm } from "../../catalog-templates";
import { assetsEnglish, assetsSpanish, inventoryKeys } from "../../shared";
import { assetsEnglish, assetsSpanish } from "../../shared";
import { inventoryKeys } from "../../shared/queries";
import { WorkstationForm, WorkstationsPage } from "../../workstations";
import { AssetInventoryPage } from "../list";

View File

@ -0,0 +1,26 @@
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO } from "../../../../common/inventory";
export type InventoryLookups = {
sites: readonly {
id: string;
name: string;
}[];
people: readonly {
id: string;
name: string;
active: boolean;
}[];
onCreateSite: (onCreated: (id: string) => void) => void;
onCreatePerson: (onCreated: (id: string) => void) => void;
};
export type AssetFormProps = {
companyId: string;
csrfToken: string;
asset?: AssetDTO | undefined;
items: readonly CatalogItemDTO[];
lookups: InventoryLookups;
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
};

View File

@ -0,0 +1 @@
export type { AssetFormProps, InventoryLookups } from "./asset-form-props";

View File

@ -1,4 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
@ -6,118 +5,43 @@ import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import type { AssetDTO, AssetInput } from "../../../../common/inventory";
import { AssetInputSchema, AssetStatusSchema } from "../../../../common/inventory";
import { AssetStatusSchema } from "../../../../common/inventory";
import { BrandSelect } from "../../../brands";
import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared";
import { InventoryFeedback } from "../../../shared";
import { useAssetFormController } from "../controllers";
import type { AssetFormProps } from "../types";
import { AssetCharacteristicControl } from "./asset-characteristic-control";
export type InventoryLookups = {
sites: readonly { id: string; name: string }[];
people: readonly { id: string; name: string; active: boolean }[];
onCreateSite: (onCreated: (id: string) => void) => void;
onCreatePerson: (onCreated: (id: string) => void) => void;
};
export function AssetForm({
companyId,
csrfToken,
asset,
items,
lookups,
onSaved,
allowQuickCreate = true,
}: {
companyId: string;
csrfToken: string;
asset?: AssetDTO | undefined;
items: readonly CatalogItemDTO[];
lookups: InventoryLookups;
onSaved: (id: string) => void;
allowQuickCreate?: boolean;
}) {
export function AssetForm(props: AssetFormProps) {
const { companyId, csrfToken, asset, items, lookups, allowQuickCreate = true } = props;
const {
creatingTemplate,
setCreatingTemplate,
form,
values,
brandFieldId,
templates,
fields,
mutation,
setCharacteristic,
submit,
selectTemplate,
changeStatus,
templateCreated,
siteCreated,
personCreated,
} = useAssetFormController(props);
const { t } = useTranslation("assets");
const prefix = useId();
const client = useQueryClient();
const [creatingTemplate, setCreatingTemplate] = useState(false);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const form = useForm<AssetInput>({
resolver: zodResolver(AssetInputSchema),
defaultValues: asset?.values ?? {
name: "",
templateId: "",
siteId: null,
personId: null,
status: "available",
notes: "",
characteristics: {},
},
});
const values = form.watch();
const brandFieldId = items.find((item) => item.referenceCatalog === "brands")?.id;
const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive);
const template = templates.find((item) => item.id === values.templateId);
const fields =
asset?.specification.fields ??
(template?.values.kind === "template"
? template.values.fields.flatMap((binding) => {
const field = items.find((item) => item.id === binding.fieldId);
return field?.values.kind === "field"
? [
{
fieldId: field.id,
name: field.values.name,
type: field.values.type,
unit: field.values.unit,
choices: field.values.choices,
required: binding.required,
},
]
: [];
})
: []);
const mutation = useMutation({
mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id),
onSuccess: async (response) => {
await Promise.all([
client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }),
client.invalidateQueries({ queryKey: ["remote-connections", companyId] }),
]);
if (mounted.current) {
onSaved(response.asset.id);
}
},
});
const setCharacteristic = (
fieldId: string,
value: AssetInput["characteristics"][string] | undefined
) => {
const characteristics = { ...form.getValues("characteristics") };
if (value === undefined) {
delete characteristics[fieldId];
} else {
characteristics[fieldId] = value;
}
form.setValue("characteristics", characteristics, { shouldDirty: true });
};
const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") };
return (
<>
<form className="space-y-5" onSubmit={form.handleSubmit((input) => mutation.mutate(input))}>
<form className="space-y-5" onSubmit={submit}>
<fieldset className="space-y-5" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
@ -135,11 +59,7 @@ export function AssetForm({
disabled={!!asset || mutation.isPending}
id={`${prefix}-template`}
label={t("template")}
onValueChange={(templateId) => {
form.setValue("templateId", templateId);
form.setValue("characteristics", {});
form.setValue("brandId", null);
}}
onValueChange={selectTemplate}
options={
asset
? [{ value: asset.values.templateId, label: asset.specification.templateName }]
@ -156,12 +76,7 @@ export function AssetForm({
disabled={mutation.isPending}
id={`${prefix}-status`}
label={t("status")}
onValueChange={(value) => {
const parsed = AssetStatusSchema.safeParse(value);
if (parsed.success) {
form.setValue("status", parsed.data);
}
}}
onValueChange={changeStatus}
options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))}
placeholder={t("chooseValue")}
required
@ -176,12 +91,7 @@ export function AssetForm({
allowQuickCreate
? {
label: t("newSite"),
onClick: () =>
lookups.onCreateSite((id) => {
if (mounted.current) {
form.setValue("siteId", id);
}
}),
onClick: () => lookups.onCreateSite(siteCreated),
}
: undefined
}
@ -200,12 +110,7 @@ export function AssetForm({
allowQuickCreate
? {
label: t("newPerson"),
onClick: () =>
lookups.onCreatePerson((id) => {
if (mounted.current) {
form.setValue("personId", id);
}
}),
onClick: () => lookups.onCreatePerson(personCreated),
}
: undefined
}
@ -301,12 +206,7 @@ export function AssetForm({
setCreatingTemplate(false);
}
}}
onSaved={(id) => {
form.setValue("templateId", id);
form.setValue("characteristics", {});
form.setValue("brandId", null);
setCreatingTemplate(false);
}}
onSaved={templateCreated}
scope={companyId}
/>
)}

View File

@ -1,3 +1,2 @@
export { AssetEditorDialog } from "./asset-editor-dialog";
export type { InventoryLookups } from "./asset-form";
export { AssetForm } from "./asset-form";

View File

@ -0,0 +1 @@
export { useBrandCatalogPageController } from "./use-brands-page-controller";

View File

@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { BrandDTO } from "../../../../common/brands";
import { brandApi, brandKeys } from "../../shared/api";
export function useBrandCatalogPageController() {
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const [edit, setEdit] = useState<BrandDTO | undefined>();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState("");
const items = (query.data?.brands ?? []).filter(
(brand) => !status || brand.active === (status === "active")
);
return { query, edit, setEdit, open, setOpen, status, setStatus, items };
}

View File

@ -0,0 +1,3 @@
export type BrandCatalogPageProps = {
csrfToken: string;
};

View File

@ -0,0 +1 @@
export type { BrandCatalogPageProps } from "./brands-page-props";

View File

@ -7,23 +7,19 @@ import {
EntityTable,
} from "@repo/shadcn-ui/components/entity-list";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useQuery } from "@tanstack/react-query";
import { useId, useState } from "react";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { BrandDTO } from "../../../../common/brands";
import { BrandEditorDialog, brandApi, brandKeys } from "../../shared";
import { BrandEditorDialog } from "../../shared";
import { useBrandCatalogPageController } from "../controllers";
import type { BrandCatalogPageProps } from "../types";
export function BrandCatalogPage({ csrfToken }: { csrfToken: string }) {
export function BrandCatalogPage(props: BrandCatalogPageProps) {
const { csrfToken } = props;
const { query, edit, setEdit, open, setOpen, status, setStatus, items } =
useBrandCatalogPageController();
const { t, i18n } = useTranslation("assets");
const id = useId();
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const [edit, setEdit] = useState<BrandDTO | undefined>();
const [open, setOpen] = useState(false);
const [status, setStatus] = useState("");
const items = (query.data?.brands ?? []).filter(
(brand) => !status || brand.active === (status === "active")
);
return (
<div className="space-y-4">
<EntityListHeader

View File

@ -0,0 +1,2 @@
export { useBrandFormController } from "./use-brand-form-controller";
export { useBrandSelectController } from "./use-brand-select-controller";

View File

@ -0,0 +1,44 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import type { BrandDTO, BrandInput } from "../../../../common/brands";
import { BrandInputSchema } from "../../../../common/brands";
import { brandApi, brandKeys } from "../api";
import type { BrandFormProps } from "../types";
export function useBrandFormController({ csrfToken, brand, onSaved }: BrandFormProps) {
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const client = useQueryClient();
const form = useForm<BrandInput>({
resolver: zodResolver(BrandInputSchema),
defaultValues: brand ? { name: brand.name, active: brand.active } : { name: "", active: true },
});
const mutation = useMutation({
mutationFn: (values: BrandInput) => brandApi.save(values, csrfToken, brand?.id),
onSuccess: async (response) => {
// Publicar primero garantiza que el selector pueda mostrar y seleccionar el nuevo valor.
client.setQueryData<{
brands: BrandDTO[];
}>(brandKeys.all, (previous) => ({
brands: [
...(previous?.brands ?? []).filter((item) => item.id !== response.brand.id),
response.brand,
],
}));
await client.invalidateQueries({ queryKey: brandKeys.all });
if (mounted.current) {
onSaved(response.brand);
}
},
});
const submit = form.handleSubmit((values) => mutation.mutate(values));
return { form, mutation, submit };
}

View File

@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { brandApi, brandKeys } from "../api";
import type { BrandSelectProps } from "../types";
export function useBrandSelectController({ brandId, legacyName }: BrandSelectProps) {
const { t } = useTranslation("assets");
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const [open, setOpen] = useState(false);
const brands = (query.data?.brands ?? []).filter((brand) => brand.active || brand.id === brandId);
// Un valor anterior permanece visible sin asociarlo o sobrescribirlo por una coincidencia de nombre.
const legacy = !brandId && legacyName !== "";
const options = brands.map((brand) => ({
value: brand.id,
label: brand.name + (brand.active ? "" : ` (${t("brands.inactive")})`),
}));
return { query, open, setOpen, brands, legacy, options };
}

View File

@ -0,0 +1,8 @@
import type { BrandDTO } from "../../../../common/brands";
export type BrandFormProps = {
csrfToken: string;
brand?: BrandDTO;
onSaved: (brand: BrandDTO) => void;
quickCreate?: boolean;
};

View File

@ -0,0 +1,13 @@
import type { BrandDTO } from "../../../../common/brands";
export type BrandSelectProps = {
id: string;
label: string;
brandId: string | null;
legacyName: string;
csrfToken: string;
disabled: boolean;
required: boolean;
allowQuickCreate: boolean;
onChange: (brand: BrandDTO | null) => void;
};

View File

@ -0,0 +1,2 @@
export type { BrandFormProps } from "./brand-form-props";
export type { BrandSelectProps } from "./brand-select-props";

View File

@ -1,61 +1,22 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useEffect, useId, useRef } from "react";
import { useForm } from "react-hook-form";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { BrandDTO, BrandInput } from "../../../../common/brands";
import { BrandInputSchema } from "../../../../common/brands";
import { BrandApiError, brandApi, brandKeys } from "../api";
import { BrandApiError } from "../api";
import { useBrandFormController } from "../controllers";
import type { BrandFormProps } from "../types";
export function BrandForm({
csrfToken,
brand,
onSaved,
quickCreate = false,
}: {
csrfToken: string;
brand?: BrandDTO;
onSaved: (brand: BrandDTO) => void;
quickCreate?: boolean;
}) {
export function BrandForm(props: BrandFormProps) {
const { brand, quickCreate = false } = props;
const { form, mutation, submit } = useBrandFormController(props);
const { t } = useTranslation("assets");
const id = useId();
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const client = useQueryClient();
const form = useForm<BrandInput>({
resolver: zodResolver(BrandInputSchema),
defaultValues: brand ? { name: brand.name, active: brand.active } : { name: "", active: true },
});
const mutation = useMutation({
mutationFn: (values: BrandInput) => brandApi.save(values, csrfToken, brand?.id),
onSuccess: async (response) => {
// Publicar primero garantiza que el selector pueda mostrar y seleccionar el nuevo valor.
client.setQueryData<{ brands: BrandDTO[] }>(brandKeys.all, (previous) => ({
brands: [
...(previous?.brands ?? []).filter((item) => item.id !== response.brand.id),
response.brand,
],
}));
await client.invalidateQueries({ queryKey: brandKeys.all });
if (mounted.current) {
onSaved(response.brand);
}
},
});
return (
<form className="space-y-5" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<form className="space-y-5" onSubmit={submit}>
<fieldset className="space-y-4" disabled={mutation.isPending}>
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>

View File

@ -1,47 +1,28 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { BrandDTO } from "../../../../common/brands";
import { brandApi, brandKeys } from "../api";
import { useBrandSelectController } from "../controllers";
import type { BrandSelectProps } from "../types";
import { BrandEditorDialog } from "./brand-editor-dialog";
const LEGACY = "legacy";
export function BrandSelect({
id,
label,
brandId,
legacyName,
csrfToken,
disabled,
required,
allowQuickCreate,
onChange,
}: {
id: string;
label: string;
brandId: string | null;
legacyName: string;
csrfToken: string;
disabled: boolean;
required: boolean;
allowQuickCreate: boolean;
onChange: (brand: BrandDTO | null) => void;
}) {
export function BrandSelect(props: BrandSelectProps) {
const {
id,
label,
brandId,
legacyName,
csrfToken,
disabled,
required,
allowQuickCreate,
onChange,
} = props;
const { query, open, setOpen, brands, legacy, options } = useBrandSelectController(props);
const { t } = useTranslation("assets");
const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list });
const [open, setOpen] = useState(false);
const brands = (query.data?.brands ?? []).filter((brand) => brand.active || brand.id === brandId);
// Un valor anterior permanece visible sin asociarlo o sobrescribirlo por una coincidencia de nombre.
const legacy = !brandId && legacyName !== "";
const options = brands.map((brand) => ({
value: brand.id,
label: brand.name + (brand.active ? "" : ` (${t("brands.inactive")})`),
}));
if (legacy) {
options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.legacy")})` });
}

View File

@ -0,0 +1 @@
export { useCatalogFieldFormController } from "./use-catalog-form-controller";

View File

@ -0,0 +1,41 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useCatalogSave } from "../../../catalog/shared/controllers";
import { FieldFormSchema } from "../schemas";
import type { CatalogFieldFormProps, FieldFormValues } from "../types";
export function useCatalogFieldFormController({
scope,
csrfToken,
item,
onSaved,
}: CatalogFieldFormProps) {
const current = item?.values.kind === "field" ? item.values : undefined;
const form = useForm<FieldFormValues>({
resolver: zodResolver(FieldFormSchema),
defaultValues: {
kind: "field",
name: current?.name ?? "",
description: current?.description ?? "",
type: current?.type ?? "text",
unit: current?.unit ?? "",
choicesText: current?.choices.join("\n") ?? "",
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const type = form.watch("type");
const submit = form.handleSubmit(({ choicesText, ...values }) =>
mutation.mutate({
...values,
choices:
values.type === "choice"
? choicesText
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean)
: [],
})
);
return { form, mutation, type, submit };
}

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
import { AssetDefinitionSchema } from "../../../../common";
export const FieldFormSchema = AssetDefinitionSchema.options[0].omit({ choices: true }).extend({
choicesText: z.string().max(10000),
});

View File

@ -0,0 +1 @@
export { FieldFormSchema } from "./catalog-form.schema";

View File

@ -0,0 +1,14 @@
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../../../../common";
import type { CatalogSaved } from "../../../catalog/shared/controllers";
import type { FieldFormSchema } from "../schemas";
export type FieldFormValues = z.infer<typeof FieldFormSchema>;
export type CatalogFieldFormProps = {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
onSaved: CatalogSaved;
};

View File

@ -0,0 +1 @@
export type { CatalogFieldFormProps, FieldFormValues } from "./catalog-form-props";

View File

@ -1,4 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
@ -6,66 +5,21 @@ import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod/v4";
import type { CatalogItemDTO } from "../../../../common";
import { AssetDefinitionSchema, FieldTypeSchema } from "../../../../common";
import type { CatalogSaved } from "../../../catalog/shared/controllers";
import { useCatalogSave } from "../../../catalog/shared/controllers";
import { FieldTypeSchema } from "../../../../common";
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
import { useCatalogFieldFormController } from "../controllers";
import type { CatalogFieldFormProps } from "../types";
const FieldFormSchema = AssetDefinitionSchema.options[0].omit({ choices: true }).extend({
choicesText: z.string().max(10000),
});
type FieldFormValues = z.infer<typeof FieldFormSchema>;
export function CatalogFieldForm({
scope,
csrfToken,
item,
onSaved,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
onSaved: CatalogSaved;
}) {
export function CatalogFieldForm(props: CatalogFieldFormProps) {
const { scope, item } = props;
const { form, mutation, type, submit } = useCatalogFieldFormController(props);
const { t } = useTranslation("assets");
const id = useId();
const current = item?.values.kind === "field" ? item.values : undefined;
const form = useForm<FieldFormValues>({
resolver: zodResolver(FieldFormSchema),
defaultValues: {
kind: "field",
name: current?.name ?? "",
description: current?.description ?? "",
type: current?.type ?? "text",
unit: current?.unit ?? "",
choicesText: current?.choices.join("\n") ?? "",
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const type = form.watch("type");
return (
<form
className="space-y-4"
onSubmit={form.handleSubmit(({ choicesText, ...values }) =>
mutation.mutate({
...values,
choices:
values.type === "choice"
? choicesText
.split(/\r?\n/)
.map((value) => value.trim())
.filter(Boolean)
: [],
})
)}
>
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />

View File

@ -0,0 +1 @@
export { useCatalogRelationFormController } from "./use-catalog-relation-form-controller";

View File

@ -0,0 +1,30 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import type { z } from "zod/v4";
import { useCatalogSave } from "../../../catalog/shared/controllers";
import { schema } from "../schemas";
import type { CatalogRelationFormProps } from "../types";
export function useCatalogRelationFormController({
scope,
csrfToken,
item,
onSaved,
}: CatalogRelationFormProps) {
const current = item?.values.kind === "relation" ? item.values : undefined;
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: current ?? {
kind: "relation",
name: "",
inverseName: "",
description: "",
symmetric: false,
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const symmetric = form.watch("symmetric");
const submit = form.handleSubmit((values) => mutation.mutate(values));
return { form, mutation, symmetric, submit };
}

View File

@ -0,0 +1,3 @@
import { AssetDefinitionSchema } from "../../../../common";
export const schema = AssetDefinitionSchema.options[2];

View File

@ -0,0 +1 @@
export { schema } from "./catalog-relation-form.schema";

View File

@ -0,0 +1,9 @@
import type { CatalogItemDTO } from "../../../../common";
import type { CatalogSaved } from "../../../catalog/shared/controllers";
export type CatalogRelationFormProps = {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
onSaved: CatalogSaved;
};

View File

@ -0,0 +1 @@
export type { CatalogRelationFormProps } from "./catalog-relation-form-props";

View File

@ -1,50 +1,23 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../../../../common";
import { AssetDefinitionSchema } from "../../../../common";
import type { CatalogSaved } from "../../../catalog/shared/controllers";
import { useCatalogSave } from "../../../catalog/shared/controllers";
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
import { useCatalogRelationFormController } from "../controllers";
import type { CatalogRelationFormProps } from "../types";
const schema = AssetDefinitionSchema.options[2];
export function CatalogRelationForm({
scope,
csrfToken,
item,
onSaved,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
onSaved: CatalogSaved;
}) {
export function CatalogRelationForm(props: CatalogRelationFormProps) {
const { scope, item } = props;
const { form, mutation, symmetric, submit } = useCatalogRelationFormController(props);
const { t } = useTranslation("assets");
const id = useId();
const current = item?.values.kind === "relation" ? item.values : undefined;
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: current ?? {
kind: "relation",
name: "",
inverseName: "",
description: "",
symmetric: false,
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const symmetric = form.watch("symmetric");
return (
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<form className="space-y-4" onSubmit={submit}>
<div className="space-y-2">
<Label htmlFor={id}>{t("name")}</Label>
<Input

View File

@ -0,0 +1 @@
export { useCatalogTemplateFormController } from "./use-catalog-template-form-controller";

View File

@ -0,0 +1,44 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useCatalogSave } from "../../../catalog/shared/controllers";
import { TemplateFormSchema } from "../schemas";
import type { CatalogTemplateFormProps, TemplateFormValues } from "../types";
export function useCatalogTemplateFormController({
scope,
csrfToken,
item,
copy,
items,
onSaved,
}: CatalogTemplateFormProps) {
const { t } = useTranslation("assets");
const [creatingField, setCreatingField] = useState(false);
const source = item ?? copy;
const current = source?.values.kind === "template" ? source.values : undefined;
const form = useForm<TemplateFormValues>({
resolver: zodResolver(TemplateFormSchema),
defaultValues: {
kind: "template",
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
description: current?.description ?? "",
fields: current?.fields.map((field) => ({ ...field })) ?? [],
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const selected = form.watch("fields");
const fields = items.filter((entry) => entry.values.kind === "field");
const add = (fieldId: string) => {
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
form.setValue("fields", [...selected, { fieldId, required: false }], {
shouldDirty: true,
shouldValidate: true,
});
}
};
const submit = form.handleSubmit((values) => mutation.mutate(values));
return { creatingField, setCreatingField, form, mutation, selected, fields, add, submit };
}

View File

@ -0,0 +1,3 @@
import { AssetDefinitionSchema } from "../../../../common";
export const TemplateFormSchema = AssetDefinitionSchema.options[1];

View File

@ -0,0 +1 @@
export { TemplateFormSchema } from "./catalog-template-form.schema";

View File

@ -0,0 +1,17 @@
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../../../../common";
import type { CatalogSaved } from "../../../catalog/shared/controllers";
import type { TemplateFormSchema } from "../schemas";
export type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
export type CatalogTemplateFormProps = {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
copy?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: CatalogSaved;
allowQuickCreate?: boolean;
};

View File

@ -0,0 +1 @@
export type { CatalogTemplateFormProps, TemplateFormValues } from "./catalog-template-form-props";

View File

@ -1,4 +1,3 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
@ -6,66 +5,24 @@ import { Input } from "@repo/shadcn-ui/components/input";
import { Label } from "@repo/shadcn-ui/components/label";
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
import { Textarea } from "@repo/shadcn-ui/components/textarea";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import type { z } from "zod/v4";
import type { CatalogItemDTO } from "../../../../common";
import { AssetDefinitionSchema } from "../../../../common";
import type { CatalogSaved } from "../../../catalog/shared/controllers";
import { useCatalogSave } from "../../../catalog/shared/controllers";
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";
const TemplateFormSchema = AssetDefinitionSchema.options[1];
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
export function CatalogTemplateForm({
scope,
csrfToken,
item,
copy,
items,
onSaved,
allowQuickCreate = true,
}: {
scope: string;
csrfToken: string;
item?: CatalogItemDTO | undefined;
copy?: CatalogItemDTO | undefined;
items: readonly CatalogItemDTO[];
onSaved: CatalogSaved;
allowQuickCreate?: boolean;
}) {
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
const { scope, csrfToken, allowQuickCreate = true } = props;
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
useCatalogTemplateFormController(props);
const { t } = useTranslation("assets");
const id = useId();
const [creatingField, setCreatingField] = useState(false);
const source = item ?? copy;
const current = source?.values.kind === "template" ? source.values : undefined;
const form = useForm<TemplateFormValues>({
resolver: zodResolver(TemplateFormSchema),
defaultValues: {
kind: "template",
name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""),
description: current?.description ?? "",
fields: current?.fields.map((field) => ({ ...field })) ?? [],
},
});
const mutation = useCatalogSave(scope, csrfToken, onSaved, item?.id);
const selected = form.watch("fields");
const fields = items.filter((entry) => entry.values.kind === "field");
const add = (fieldId: string) => {
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
form.setValue("fields", [...selected, { fieldId, required: false }], {
shouldDirty: true,
shouldValidate: true,
});
}
};
return (
<>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`${id}-name`}>{t("name")}</Label>

View File

@ -1,2 +1,3 @@
export { AssetCatalogPage } from "./list";
export { CatalogError, catalogApi, catalogKeys } from "./shared";
export { CatalogError } from "./shared";
export { catalogApi, catalogKeys } from "./shared/api";

View File

@ -0,0 +1 @@
export { useAssetCatalogPageController } from "./use-catalog-page-controller";

View File

@ -0,0 +1,48 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import type { CatalogItemDTO } from "../../../../common";
import type { CatalogEditor } from "../../shared";
import { catalogApi, catalogKeys } from "../../shared/api";
import type { AssetCatalogPageProps } from "../types";
export function useAssetCatalogPageController({ scope, csrfToken }: AssetCatalogPageProps) {
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});
const mutation = useMutation({
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
catalogApi.status(scope, item.id, active, csrfToken),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), response);
},
});
const items = query.data?.items ?? [];
const view = items.find((item) => item.id === viewId);
const visible = items.filter(
(item) =>
item.values.kind === tab &&
item.values.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
return {
tab,
setTab,
search,
setSearch,
setViewId,
editor,
setEditor,
query,
mutation,
items,
view,
visible,
};
}

View File

@ -0,0 +1,4 @@
export type AssetCatalogPageProps = {
scope: string;
csrfToken: string;
};

View File

@ -0,0 +1 @@
export type { AssetCatalogPageProps } from "./catalog-page-props";

View File

@ -1,42 +1,31 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
import { CatalogDefinitionDialog } from "../../detail";
import type { CatalogEditor } from "../../shared";
import { CatalogEditorDialog, CatalogError, catalogApi, catalogKeys } from "../../shared";
import { CatalogEditorDialog, CatalogError } from "../../shared";
import { useAssetCatalogPageController } from "../controllers";
import type { AssetCatalogPageProps } from "../types";
import { CatalogCell } from "./catalog-cell";
export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) {
export function AssetCatalogPage(props: AssetCatalogPageProps) {
const { scope, csrfToken } = props;
const {
tab,
setTab,
search,
setSearch,
setViewId,
editor,
setEditor,
query,
mutation,
items,
view,
visible,
} = useAssetCatalogPageController(props);
const { t, i18n } = useTranslation("assets");
const client = useQueryClient();
const [tab, setTab] = useState<"field" | "template" | "relation">("template");
const [search, setSearch] = useState("");
const [viewId, setViewId] = useState<string>();
const [editor, setEditor] = useState<CatalogEditor>();
const query = useQuery({
queryKey: catalogKeys.scope(scope),
queryFn: () => catalogApi.get(scope),
});
const mutation = useMutation({
mutationFn: ({ item, active }: { item: CatalogItemDTO; active: boolean | null }) =>
catalogApi.status(scope, item.id, active, csrfToken),
onSuccess: async (response) => {
await client.invalidateQueries({ queryKey: ["asset-catalog"] });
client.setQueryData(catalogKeys.scope(scope), response);
},
});
const items = query.data?.items ?? [];
const view = items.find((item) => item.id === viewId);
const visible = items.filter(
(item) =>
item.values.kind === tab &&
item.values.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
);
return (
<div className="space-y-6">
<EntityListHeader

View File

@ -1,6 +1,9 @@
export { AssetForm, AssetInventoryPage } from "./assets";
export { BrandCatalogPage } from "./brands";
export { AssetCatalogPage, catalogApi, catalogKeys } from "./catalog";
export { assetsEnglish, assetsSpanish, inventoryApi, inventoryKeys } from "./shared";
export { AssetCatalogPage } from "./catalog";
export { catalogApi, catalogKeys } from "./catalog/shared/api";
export { assetsEnglish, assetsSpanish } from "./shared";
export { inventoryApi } from "./shared/api";
export { inventoryKeys } from "./shared/queries";
export { AssetSupport } from "./support-connections";
export { WorkstationForm, WorkstationsPage } from "./workstations";

View File

@ -1,2 +1,3 @@
export { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
export { SupportApiError, supportApi, supportKeys } from "./support-api";
export { inventoryApi } from "./inventory-api";
export { InventoryApiError } from "./inventory-api-error";
export { SupportApiError } from "./support-api-error";

View File

@ -0,0 +1,5 @@
export class InventoryApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -1,56 +1,13 @@
import { z } from "zod/v4";
import { createAssetRelationship } from "../../asset-relationships/create/api/create-asset-relationship.api";
import { endAssetRelationship } from "../../asset-relationships/end/api/end-asset-relationship.api";
import { getAssetInventory } from "../../assets/list/api/get-asset-inventory.api";
import { saveAsset } from "../../assets/shared/api/save-asset.api";
import { saveWorkstation } from "../../workstations/shared/api/save-workstation.api";
import type { AssetInput } from "../../../common/inventory";
import { AssetResponseSchema, InventoryResponseSchema } from "../../../common/inventory";
import type { RelationshipInputDTO } from "../../../common/relationships";
import { RelationshipResponseSchema } from "../../../common/relationships";
import type { WorkstationInput } from "../../../common/workstations";
import { WorkstationResponseSchema } from "../../../common/workstations";
export const inventoryKeys = { company: (id: string) => ["inventory", id] };
export class InventoryApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, body?: unknown, csrfToken?: string) {
const response = await fetch(`/api/assets/${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}
export const inventoryApi = {
async saveWorkstation(companyId: string, values: WorkstationInput, token: string, id?: string) {
return WorkstationResponseSchema.parse(
await request(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }, token)
);
},
async link(companyId: string, values: RelationshipInputDTO, token: string) {
return RelationshipResponseSchema.parse(
await request(`${companyId}/relationships`, { values }, token)
);
},
async endRelationship(companyId: string, id: string, token: string) {
return RelationshipResponseSchema.parse(
await request(`${companyId}/relationships/${id}/end`, {}, token)
);
},
async get(companyId: string) {
return InventoryResponseSchema.parse(await request(companyId));
},
async save(companyId: string, values: AssetInput, token: string, id?: string) {
return AssetResponseSchema.parse(
await request(`${companyId}${id ? `/${id}/update` : ""}`, { values }, token)
);
},
saveWorkstation: saveWorkstation,
link: createAssetRelationship,
endRelationship: endAssetRelationship,
get: getAssetInventory,
save: saveAsset,
};

View File

@ -0,0 +1,21 @@
import { z } from "zod/v4";
import { InventoryApiError } from "./inventory-api-error";
export async function inventoryRequest(path: string, body?: unknown, csrfToken?: string) {
const response = await fetch(`/api/assets/${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
body === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}

View File

@ -0,0 +1,5 @@
export class SupportApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}

View File

@ -1,53 +0,0 @@
import { z } from "zod/v4";
import type { SupportInput, SupportToolInput } from "../../../common/support";
import {
SupportListSchema,
SupportResponseSchema,
SupportToolResponseSchema,
SupportToolsSchema,
} from "../../../common/support";
export const supportKeys = {
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
tools: ["support-tools"],
};
export class SupportApiError extends Error {
constructor(public readonly code: string) {
super(code);
}
}
async function request(path: string, values?: unknown, token?: string) {
const response = await fetch(`/api/asset-support/${path}`, {
method: values === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
values === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": token ?? "" },
...(values === undefined ? {} : { body: JSON.stringify({ values }) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new SupportApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}
export const supportApi = {
async get(companyId: string, assetId: string) {
return SupportListSchema.parse(await request(`${companyId}/${assetId}`));
},
async tools() {
return SupportToolsSchema.parse(await request("tools"));
},
async save(companyId: string, assetId: string, values: SupportInput, token: string, id?: string) {
return SupportResponseSchema.parse(
await request(`${companyId}/${assetId}${id ? `/${id}/update` : ""}`, values, token)
);
},
async saveTool(values: SupportToolInput, token: string, id?: string) {
return SupportToolResponseSchema.parse(
await request(`tools${id ? `/${id}/update` : ""}`, values, token)
);
},
};

View File

@ -0,0 +1,21 @@
import { z } from "zod/v4";
import { SupportApiError } from "./support-api-error";
export async function supportRequest(path: string, values?: unknown, token?: string) {
const response = await fetch(`/api/asset-support/${path}`, {
method: values === undefined ? "GET" : "POST",
credentials: "same-origin",
headers:
values === undefined
? {}
: { "content-type": "application/json", "x-csrf-token": token ?? "" },
...(values === undefined ? {} : { body: JSON.stringify({ values }) }),
});
const data: unknown = await response.json();
if (!response.ok) {
const error = z.object({ code: z.string() }).safeParse(data);
throw new SupportApiError(error.success ? error.data.code : "INTERNAL_ERROR");
}
return data;
}

View File

@ -1,3 +1,4 @@
export { inventoryApi, inventoryKeys, supportApi, supportKeys } from "./api";
export { inventoryApi } from "./api";
export { assetsEnglish, assetsSpanish } from "./i18n";
export { inventoryKeys, supportKeys } from "./queries";
export { InventoryFeedback, SupportFeedback } from "./ui";

View File

@ -0,0 +1,2 @@
export { inventoryKeys } from "./inventory-query-keys";
export { supportKeys } from "./support-query-keys";

View File

@ -0,0 +1 @@
export const inventoryKeys = { company: (id: string) => ["inventory", id] };

View File

@ -0,0 +1,4 @@
export const supportKeys = {
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
tools: ["support-tools"],
};

View File

@ -0,0 +1,6 @@
import { SupportListSchema } from "../../../../common/support";
import { supportRequest } from "../../../shared/api/support-request";
export async function getSupportConnections(companyId: string, assetId: string) {
return SupportListSchema.parse(await supportRequest(`${companyId}/${assetId}`));
}

View File

@ -0,0 +1 @@
export { getSupportConnections } from "./get-support-connections.api";

Some files were not shown because too many files have changed in this diff Show More