Reorganizar el codigo de cliente por responsabilidades.
This commit is contained in:
parent
f5dd8fdf31
commit
9733f550a5
12
TODO.md
12
TODO.md
@ -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.
|
||||
|
||||
|
||||
85
docs/auditoria-de-separacion-logica-del-cliente.md
Normal file
85
docs/auditoria-de-separacion-logica-del-cliente.md
Normal 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.
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { createAssetRelationship } from "./create-asset-relationship.api";
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { endAssetRelationship } from "./end-asset-relationship.api";
|
||||
@ -0,0 +1 @@
|
||||
export { useEndRelationshipDialogController } from "./use-end-relationship-dialog-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import type { RelationshipDTO } from "../../../../common";
|
||||
|
||||
export type EndRelationshipDialogProps = {
|
||||
relationship: RelationshipDTO;
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
sourceName: string;
|
||||
targetName: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export type { EndRelationshipDialogProps } from "./end-relationship-dialog-props";
|
||||
@ -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")}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { useAssetRelationshipsController } from "./use-asset-relationships-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
export type AssetRelationshipsProps = {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
assetId: string;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export type { AssetRelationshipsProps } from "./asset-relationships-props";
|
||||
@ -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 (
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { useRelationshipFormController } from "./use-relationship-form-controller";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export type { RelationshipFormProps } from "./relationship-form-props";
|
||||
@ -0,0 +1,6 @@
|
||||
export type RelationshipFormProps = {
|
||||
companyId: string;
|
||||
csrfToken: string;
|
||||
assetId: string;
|
||||
onSaved: () => void;
|
||||
};
|
||||
@ -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}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { useAssetDetailController } from "./use-asset-detail-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
1
modules/assets/src/web/assets/detail/types/index.ts
Normal file
1
modules/assets/src/web/assets/detail/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { AssetDetailProps } from "./asset-detail-props";
|
||||
@ -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")
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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));
|
||||
}
|
||||
1
modules/assets/src/web/assets/list/api/index.ts
Normal file
1
modules/assets/src/web/assets/list/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { getAssetInventory } from "./get-asset-inventory.api";
|
||||
1
modules/assets/src/web/assets/list/controllers/index.ts
Normal file
1
modules/assets/src/web/assets/list/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useAssetInventoryPageController } from "./use-inventory-page-controller";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
1
modules/assets/src/web/assets/list/types/index.ts
Normal file
1
modules/assets/src/web/assets/list/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { AssetInventoryPageProps } from "./inventory-page-props";
|
||||
@ -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;
|
||||
};
|
||||
@ -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"
|
||||
: "";
|
||||
}
|
||||
|
||||
1
modules/assets/src/web/assets/shared/api/index.ts
Normal file
1
modules/assets/src/web/assets/shared/api/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { saveAsset } from "./save-asset.api";
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export { useAssetFormController } from "./use-asset-form-controller";
|
||||
@ -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";
|
||||
@ -1,2 +1,2 @@
|
||||
export type { InventoryLookups } from "./ui";
|
||||
export type { InventoryLookups } from "./types";
|
||||
export { AssetEditorDialog, AssetForm } from "./ui";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
1
modules/assets/src/web/assets/shared/types/index.ts
Normal file
1
modules/assets/src/web/assets/shared/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { AssetFormProps, InventoryLookups } from "./asset-form-props";
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export { AssetEditorDialog } from "./asset-editor-dialog";
|
||||
export type { InventoryLookups } from "./asset-form";
|
||||
export { AssetForm } from "./asset-form";
|
||||
|
||||
1
modules/assets/src/web/brands/list/controllers/index.ts
Normal file
1
modules/assets/src/web/brands/list/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useBrandCatalogPageController } from "./use-brands-page-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export type BrandCatalogPageProps = {
|
||||
csrfToken: string;
|
||||
};
|
||||
1
modules/assets/src/web/brands/list/types/index.ts
Normal file
1
modules/assets/src/web/brands/list/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { BrandCatalogPageProps } from "./brands-page-props";
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export { useBrandFormController } from "./use-brand-form-controller";
|
||||
export { useBrandSelectController } from "./use-brand-select-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -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 };
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import type { BrandDTO } from "../../../../common/brands";
|
||||
|
||||
export type BrandFormProps = {
|
||||
csrfToken: string;
|
||||
brand?: BrandDTO;
|
||||
onSaved: (brand: BrandDTO) => void;
|
||||
quickCreate?: boolean;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
2
modules/assets/src/web/brands/shared/types/index.ts
Normal file
2
modules/assets/src/web/brands/shared/types/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { BrandFormProps } from "./brand-form-props";
|
||||
export type { BrandSelectProps } from "./brand-select-props";
|
||||
@ -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>
|
||||
|
||||
@ -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")})` });
|
||||
}
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { useCatalogFieldFormController } from "./use-catalog-form-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -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),
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export { FieldFormSchema } from "./catalog-form.schema";
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export type { CatalogFieldFormProps, FieldFormValues } from "./catalog-form-props";
|
||||
@ -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")} />
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { useCatalogRelationFormController } from "./use-catalog-relation-form-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import { AssetDefinitionSchema } from "../../../../common";
|
||||
|
||||
export const schema = AssetDefinitionSchema.options[2];
|
||||
@ -0,0 +1 @@
|
||||
export { schema } from "./catalog-relation-form.schema";
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export type { CatalogRelationFormProps } from "./catalog-relation-form-props";
|
||||
@ -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
|
||||
|
||||
@ -0,0 +1 @@
|
||||
export { useCatalogTemplateFormController } from "./use-catalog-template-form-controller";
|
||||
@ -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 };
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
import { AssetDefinitionSchema } from "../../../../common";
|
||||
|
||||
export const TemplateFormSchema = AssetDefinitionSchema.options[1];
|
||||
@ -0,0 +1 @@
|
||||
export { TemplateFormSchema } from "./catalog-template-form.schema";
|
||||
@ -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;
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export type { CatalogTemplateFormProps, TemplateFormValues } from "./catalog-template-form-props";
|
||||
@ -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>
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export { AssetCatalogPage } from "./list";
|
||||
export { CatalogError, catalogApi, catalogKeys } from "./shared";
|
||||
export { CatalogError } from "./shared";
|
||||
export { catalogApi, catalogKeys } from "./shared/api";
|
||||
|
||||
1
modules/assets/src/web/catalog/list/controllers/index.ts
Normal file
1
modules/assets/src/web/catalog/list/controllers/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useAssetCatalogPageController } from "./use-catalog-page-controller";
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export type AssetCatalogPageProps = {
|
||||
scope: string;
|
||||
csrfToken: string;
|
||||
};
|
||||
1
modules/assets/src/web/catalog/list/types/index.ts
Normal file
1
modules/assets/src/web/catalog/list/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export type { AssetCatalogPageProps } from "./catalog-page-props";
|
||||
@ -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
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
5
modules/assets/src/web/shared/api/inventory-api-error.ts
Normal file
5
modules/assets/src/web/shared/api/inventory-api-error.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class InventoryApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
|
||||
21
modules/assets/src/web/shared/api/inventory-request.ts
Normal file
21
modules/assets/src/web/shared/api/inventory-request.ts
Normal 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;
|
||||
}
|
||||
5
modules/assets/src/web/shared/api/support-api-error.ts
Normal file
5
modules/assets/src/web/shared/api/support-api-error.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export class SupportApiError extends Error {
|
||||
constructor(public readonly code: string) {
|
||||
super(code);
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
},
|
||||
};
|
||||
21
modules/assets/src/web/shared/api/support-request.ts
Normal file
21
modules/assets/src/web/shared/api/support-request.ts
Normal 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;
|
||||
}
|
||||
@ -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";
|
||||
|
||||
2
modules/assets/src/web/shared/queries/index.ts
Normal file
2
modules/assets/src/web/shared/queries/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { inventoryKeys } from "./inventory-query-keys";
|
||||
export { supportKeys } from "./support-query-keys";
|
||||
@ -0,0 +1 @@
|
||||
export const inventoryKeys = { company: (id: string) => ["inventory", id] };
|
||||
@ -0,0 +1,4 @@
|
||||
export const supportKeys = {
|
||||
asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId],
|
||||
tools: ["support-tools"],
|
||||
};
|
||||
@ -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}`));
|
||||
}
|
||||
@ -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
Loading…
Reference in New Issue
Block a user