Reorganizar el codigo de cliente por responsabilidades.
This commit is contained in:
parent
f5dd8fdf31
commit
9733f550a5
10
TODO.md
10
TODO.md
@ -428,3 +428,13 @@ El arbol completo para este ejemplo de "facturas" quedaría así:
|
|||||||
```
|
```
|
||||||
|
|
||||||
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
|
||||||
index.ts
|
index.ts
|
||||||
list/
|
list/
|
||||||
|
controllers/
|
||||||
|
use-tickets-page-controller.ts
|
||||||
|
index.ts
|
||||||
|
helpers/
|
||||||
|
tickets-filtering.ts
|
||||||
|
index.ts
|
||||||
|
types/
|
||||||
|
tickets-page-props.ts
|
||||||
|
index.ts
|
||||||
ui/
|
ui/
|
||||||
tickets-page.tsx
|
tickets-page.tsx
|
||||||
index.ts
|
index.ts
|
||||||
@ -52,6 +61,15 @@ tickets/
|
|||||||
api/
|
api/
|
||||||
ticket-api.ts
|
ticket-api.ts
|
||||||
index.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/
|
ui/
|
||||||
ticket-form.tsx
|
ticket-form.tsx
|
||||||
ticket-editor-dialog.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:
|
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
|
`<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
|
que abarcan varias entidades se separan por sus operaciones reales. Por ejemplo,
|
||||||
`assets/shared/api/inventory-api.ts`, pueden permanecer compartidos. No dividir un cliente
|
`users/create/api/create-user.api.ts`, `people/shared/api/save-person.api.ts` y
|
||||||
ni alterar sus firmas, queries o contratos únicamente para reproducir el árbol de ejemplo.
|
`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
|
## Í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
|
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
|
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
|
se aplazó por decisión del usuario. La auditoría posterior separó la lógica de formularios,
|
||||||
nuevo de las reglas de separación de responsabilidades de `AGENTS.md`.
|
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),
|
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)
|
[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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { RelationshipDTO } from "../../../../common";
|
import { InventoryFeedback } from "../../../shared";
|
||||||
import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared";
|
import { useEndRelationshipDialogController } from "../controllers";
|
||||||
|
import type { EndRelationshipDialogProps } from "../types";
|
||||||
|
|
||||||
export function EndRelationshipDialog({
|
export function EndRelationshipDialog(props: EndRelationshipDialogProps) {
|
||||||
relationship,
|
const { relationship, sourceName, targetName, onClose } = props;
|
||||||
companyId,
|
const { mutation } = useEndRelationshipDialogController(props);
|
||||||
csrfToken,
|
|
||||||
sourceName,
|
|
||||||
targetName,
|
|
||||||
onClose,
|
|
||||||
}: {
|
|
||||||
relationship: RelationshipDTO;
|
|
||||||
companyId: string;
|
|
||||||
csrfToken: string;
|
|
||||||
sourceName: string;
|
|
||||||
targetName: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
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 (
|
return (
|
||||||
<FormDialog
|
<FormDialog
|
||||||
cancelLabel={t("cancel")}
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { RelationshipDTO } from "../../../../common";
|
import type { RelationshipDTO } from "../../../../common";
|
||||||
import { inventoryApi, inventoryKeys } from "../../../shared";
|
|
||||||
import { EndRelationshipDialog } from "../../end";
|
import { EndRelationshipDialog } from "../../end";
|
||||||
import { RelationshipEditorDialog } from "../../shared";
|
import { RelationshipEditorDialog } from "../../shared";
|
||||||
|
import { useAssetRelationshipsController } from "../controllers";
|
||||||
|
import type { AssetRelationshipsProps } from "../types";
|
||||||
|
|
||||||
export function AssetRelationships({
|
export function AssetRelationships(props: AssetRelationshipsProps) {
|
||||||
companyId,
|
const { companyId, csrfToken, assetId } = props;
|
||||||
csrfToken,
|
const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } =
|
||||||
assetId,
|
useAssetRelationshipsController(props);
|
||||||
}: {
|
const { t } = useTranslation("assets");
|
||||||
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);
|
|
||||||
const nameOf = (id: string) =>
|
const nameOf = (id: string) =>
|
||||||
query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id;
|
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 render = (link: RelationshipDTO) => {
|
||||||
const source = link.values.sourceId === assetId;
|
const source = link.values.sourceId === assetId;
|
||||||
return (
|
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 { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useId } from "react";
|
||||||
import { useId, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { RelationshipInputDTO } from "../../../../common";
|
|
||||||
import { catalogApi, catalogKeys } from "../../../catalog";
|
|
||||||
import { CatalogRelationCreateDialog } from "../../../catalog-relations";
|
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({
|
export function RelationshipForm(props: RelationshipFormProps) {
|
||||||
companyId,
|
const { companyId, csrfToken } = props;
|
||||||
csrfToken,
|
const {
|
||||||
assetId,
|
typeId,
|
||||||
onSaved,
|
setTypeId,
|
||||||
}: {
|
targetId,
|
||||||
companyId: string;
|
setTargetId,
|
||||||
csrfToken: string;
|
notes,
|
||||||
assetId: string;
|
setNotes,
|
||||||
onSaved: () => void;
|
quick,
|
||||||
}) {
|
setQuick,
|
||||||
|
catalog,
|
||||||
|
inventory,
|
||||||
|
mutation,
|
||||||
|
submit,
|
||||||
|
relationshipTypes,
|
||||||
|
targetAssets,
|
||||||
|
} = useRelationshipFormController(props);
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const id = useId();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<form
|
<form className="space-y-4" onSubmit={submit}>
|
||||||
className="space-y-4"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
mutation.mutate({ sourceId: assetId, targetId, typeId, notes });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
|
<Label htmlFor={`${id}-type`}>{t("relationshipType")}</Label>
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
@ -63,9 +43,7 @@ export function RelationshipForm({
|
|||||||
id={`${id}-type`}
|
id={`${id}-type`}
|
||||||
label={t("relationshipType")}
|
label={t("relationshipType")}
|
||||||
onValueChange={setTypeId}
|
onValueChange={setTypeId}
|
||||||
options={(catalog.data?.items ?? [])
|
options={relationshipTypes.map((item) => ({ value: item.id, label: item.values.name }))}
|
||||||
.filter((item) => item.values.kind === "relation" && item.effectiveActive)
|
|
||||||
.map((item) => ({ value: item.id, label: item.values.name }))}
|
|
||||||
placeholder={t("chooseRelation")}
|
placeholder={t("chooseRelation")}
|
||||||
required
|
required
|
||||||
value={typeId}
|
value={typeId}
|
||||||
@ -78,9 +56,7 @@ export function RelationshipForm({
|
|||||||
id={`${id}-target`}
|
id={`${id}-target`}
|
||||||
label={t("targetAsset")}
|
label={t("targetAsset")}
|
||||||
onValueChange={setTargetId}
|
onValueChange={setTargetId}
|
||||||
options={(inventory.data?.assets ?? [])
|
options={targetAssets.map((asset) => ({ value: asset.id, label: asset.values.name }))}
|
||||||
.filter((asset) => asset.id !== assetId)
|
|
||||||
.map((asset) => ({ value: asset.id, label: asset.values.name }))}
|
|
||||||
placeholder={t("chooseAsset")}
|
placeholder={t("chooseAsset")}
|
||||||
required
|
required
|
||||||
value={targetId}
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import { useAssetDetailController } from "../controllers";
|
||||||
import { inventoryApi, inventoryKeys } from "../../../shared";
|
import type { AssetDetailProps } from "../types";
|
||||||
import type { InventoryLookups } from "../../shared";
|
|
||||||
export function AssetDetail({
|
export function AssetDetail(props: AssetDetailProps) {
|
||||||
asset,
|
const { asset, lookups, compact = false, children } = props;
|
||||||
lookups,
|
const { workstations } = useAssetDetailController(props);
|
||||||
compact = false,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
asset: AssetDTO;
|
|
||||||
lookups: InventoryLookups;
|
|
||||||
compact?: boolean;
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const inventory = useQuery({
|
const nameOf = (
|
||||||
queryKey: inventoryKeys.company(asset.companyId),
|
id: string | null,
|
||||||
queryFn: () => inventoryApi.get(asset.companyId),
|
options: readonly {
|
||||||
});
|
id: string;
|
||||||
const workstations = inventory.data?.workstations.filter(
|
name: string;
|
||||||
(item) =>
|
}[]
|
||||||
item.values.active &&
|
) => (id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment"));
|
||||||
(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 display = (value: string | number | boolean | undefined) =>
|
const display = (value: string | number | boolean | undefined) =>
|
||||||
value === undefined
|
value === undefined
|
||||||
? t("noValue")
|
? t("noValue")
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
import { AssetRelationships } from "../../../asset-relationships";
|
import { AssetRelationships } from "../../../asset-relationships";
|
||||||
import { AssetSupport } from "../../../support-connections";
|
import { AssetSupport } from "../../../support-connections";
|
||||||
import type { InventoryLookups } from "../../shared";
|
import type { InventoryLookups } from "../../shared/types";
|
||||||
|
|
||||||
import { AssetDetail } from "./asset-detail";
|
import { AssetDetail } from "./asset-detail";
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
export { WorkstationAssetCreateDialog } from "./create";
|
export { WorkstationAssetCreateDialog } from "./create";
|
||||||
export { AssetInventoryPage } from "./list";
|
export { AssetInventoryPage } from "./list";
|
||||||
export type { InventoryLookups } from "./shared";
|
|
||||||
export { AssetForm } 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,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/shadcn-ui/components/entity-list";
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CatalogError, catalogApi, catalogKeys } from "../../../catalog";
|
import { CatalogError } from "../../../catalog";
|
||||||
import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared";
|
import { InventoryFeedback } from "../../../shared";
|
||||||
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
||||||
import type { InventoryLookups } from "../../shared";
|
|
||||||
import { AssetEditorDialog } from "../../shared";
|
import { AssetEditorDialog } from "../../shared";
|
||||||
|
import { useAssetInventoryPageController } from "../controllers";
|
||||||
|
import type { AssetInventoryPageProps } from "../types";
|
||||||
|
|
||||||
export function AssetInventoryPage({
|
function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) {
|
||||||
companyId,
|
return id === selectedId && detailOpen
|
||||||
csrfToken,
|
? "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"
|
||||||
lookups,
|
: "";
|
||||||
initialId,
|
}
|
||||||
initialCreate = false,
|
export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
||||||
}: {
|
const { companyId, csrfToken, lookups } = props;
|
||||||
companyId: string;
|
const {
|
||||||
csrfToken: string;
|
query,
|
||||||
lookups: InventoryLookups;
|
catalog,
|
||||||
initialId?: string | undefined;
|
search,
|
||||||
initialCreate?: boolean | undefined;
|
setSearch,
|
||||||
}) {
|
editor,
|
||||||
|
setEditor,
|
||||||
|
setViewId,
|
||||||
|
setSelectedId,
|
||||||
|
detailOpen,
|
||||||
|
setDetailOpen,
|
||||||
|
selected,
|
||||||
|
edited,
|
||||||
|
viewed,
|
||||||
|
filtered,
|
||||||
|
} = useAssetInventoryPageController(props);
|
||||||
const { t, i18n } = useTranslation("assets");
|
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 (
|
return (
|
||||||
<div
|
<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)]"
|
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>
|
</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";
|
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 { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
||||||
import type { RelationshipDTO } from "../../../common";
|
import type { RelationshipDTO } from "../../../common";
|
||||||
import { AssetRelationships } from "../../asset-relationships";
|
import { AssetRelationships } from "../../asset-relationships";
|
||||||
import { catalogKeys } from "../../catalog";
|
import { catalogKeys } from "../../catalog/shared/api";
|
||||||
import { CatalogRelationForm } from "../../catalog-relations";
|
import { CatalogRelationForm } from "../../catalog-relations";
|
||||||
import { CatalogTemplateForm } from "../../catalog-templates";
|
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 { WorkstationForm, WorkstationsPage } from "../../workstations";
|
||||||
import { AssetInventoryPage } from "../list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
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 { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useId } from "react";
|
||||||
import { useEffect, useId, useRef, useState } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../../../../common";
|
import { AssetStatusSchema } from "../../../../common/inventory";
|
||||||
import type { AssetDTO, AssetInput } from "../../../../common/inventory";
|
|
||||||
import { AssetInputSchema, AssetStatusSchema } from "../../../../common/inventory";
|
|
||||||
import { BrandSelect } from "../../../brands";
|
import { BrandSelect } from "../../../brands";
|
||||||
import { CatalogTemplateCreateDialog } from "../../../catalog-templates";
|
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";
|
import { AssetCharacteristicControl } from "./asset-characteristic-control";
|
||||||
|
|
||||||
export type InventoryLookups = {
|
export function AssetForm(props: AssetFormProps) {
|
||||||
sites: readonly { id: string; name: string }[];
|
const { companyId, csrfToken, asset, items, lookups, allowQuickCreate = true } = props;
|
||||||
people: readonly { id: string; name: string; active: boolean }[];
|
const {
|
||||||
onCreateSite: (onCreated: (id: string) => void) => void;
|
creatingTemplate,
|
||||||
onCreatePerson: (onCreated: (id: string) => void) => void;
|
setCreatingTemplate,
|
||||||
};
|
form,
|
||||||
|
values,
|
||||||
export function AssetForm({
|
brandFieldId,
|
||||||
companyId,
|
templates,
|
||||||
csrfToken,
|
fields,
|
||||||
asset,
|
mutation,
|
||||||
items,
|
setCharacteristic,
|
||||||
lookups,
|
submit,
|
||||||
onSaved,
|
selectTemplate,
|
||||||
allowQuickCreate = true,
|
changeStatus,
|
||||||
}: {
|
templateCreated,
|
||||||
companyId: string;
|
siteCreated,
|
||||||
csrfToken: string;
|
personCreated,
|
||||||
asset?: AssetDTO | undefined;
|
} = useAssetFormController(props);
|
||||||
items: readonly CatalogItemDTO[];
|
|
||||||
lookups: InventoryLookups;
|
|
||||||
onSaved: (id: string) => void;
|
|
||||||
allowQuickCreate?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const prefix = useId();
|
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") };
|
const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") };
|
||||||
return (
|
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}>
|
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
<Label htmlFor={`${prefix}-name`}>{t("name")}</Label>
|
||||||
@ -135,11 +59,7 @@ export function AssetForm({
|
|||||||
disabled={!!asset || mutation.isPending}
|
disabled={!!asset || mutation.isPending}
|
||||||
id={`${prefix}-template`}
|
id={`${prefix}-template`}
|
||||||
label={t("template")}
|
label={t("template")}
|
||||||
onValueChange={(templateId) => {
|
onValueChange={selectTemplate}
|
||||||
form.setValue("templateId", templateId);
|
|
||||||
form.setValue("characteristics", {});
|
|
||||||
form.setValue("brandId", null);
|
|
||||||
}}
|
|
||||||
options={
|
options={
|
||||||
asset
|
asset
|
||||||
? [{ value: asset.values.templateId, label: asset.specification.templateName }]
|
? [{ value: asset.values.templateId, label: asset.specification.templateName }]
|
||||||
@ -156,12 +76,7 @@ export function AssetForm({
|
|||||||
disabled={mutation.isPending}
|
disabled={mutation.isPending}
|
||||||
id={`${prefix}-status`}
|
id={`${prefix}-status`}
|
||||||
label={t("status")}
|
label={t("status")}
|
||||||
onValueChange={(value) => {
|
onValueChange={changeStatus}
|
||||||
const parsed = AssetStatusSchema.safeParse(value);
|
|
||||||
if (parsed.success) {
|
|
||||||
form.setValue("status", parsed.data);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))}
|
options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))}
|
||||||
placeholder={t("chooseValue")}
|
placeholder={t("chooseValue")}
|
||||||
required
|
required
|
||||||
@ -176,12 +91,7 @@ export function AssetForm({
|
|||||||
allowQuickCreate
|
allowQuickCreate
|
||||||
? {
|
? {
|
||||||
label: t("newSite"),
|
label: t("newSite"),
|
||||||
onClick: () =>
|
onClick: () => lookups.onCreateSite(siteCreated),
|
||||||
lookups.onCreateSite((id) => {
|
|
||||||
if (mounted.current) {
|
|
||||||
form.setValue("siteId", id);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@ -200,12 +110,7 @@ export function AssetForm({
|
|||||||
allowQuickCreate
|
allowQuickCreate
|
||||||
? {
|
? {
|
||||||
label: t("newPerson"),
|
label: t("newPerson"),
|
||||||
onClick: () =>
|
onClick: () => lookups.onCreatePerson(personCreated),
|
||||||
lookups.onCreatePerson((id) => {
|
|
||||||
if (mounted.current) {
|
|
||||||
form.setValue("personId", id);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@ -301,12 +206,7 @@ export function AssetForm({
|
|||||||
setCreatingTemplate(false);
|
setCreatingTemplate(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSaved={(id) => {
|
onSaved={templateCreated}
|
||||||
form.setValue("templateId", id);
|
|
||||||
form.setValue("characteristics", {});
|
|
||||||
form.setValue("brandId", null);
|
|
||||||
setCreatingTemplate(false);
|
|
||||||
}}
|
|
||||||
scope={companyId}
|
scope={companyId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,3 +1,2 @@
|
|||||||
export { AssetEditorDialog } from "./asset-editor-dialog";
|
export { AssetEditorDialog } from "./asset-editor-dialog";
|
||||||
export type { InventoryLookups } from "./asset-form";
|
|
||||||
export { AssetForm } 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,
|
EntityTable,
|
||||||
} from "@repo/shadcn-ui/components/entity-list";
|
} from "@repo/shadcn-ui/components/entity-list";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useId } from "react";
|
||||||
import { useId, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BrandDTO } from "../../../../common/brands";
|
import { BrandEditorDialog } from "../../shared";
|
||||||
import { BrandEditorDialog, brandApi, brandKeys } 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 { t, i18n } = useTranslation("assets");
|
||||||
const id = useId();
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<EntityListHeader
|
<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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
import { ContextHelp } from "@repo/shadcn-ui/components/context-help";
|
||||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useId } from "react";
|
||||||
import { useEffect, useId, useRef } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BrandDTO, BrandInput } from "../../../../common/brands";
|
import { BrandApiError } from "../api";
|
||||||
import { BrandInputSchema } from "../../../../common/brands";
|
import { useBrandFormController } from "../controllers";
|
||||||
import { BrandApiError, brandApi, brandKeys } from "../api";
|
import type { BrandFormProps } from "../types";
|
||||||
|
|
||||||
export function BrandForm({
|
export function BrandForm(props: BrandFormProps) {
|
||||||
csrfToken,
|
const { brand, quickCreate = false } = props;
|
||||||
brand,
|
const { form, mutation, submit } = useBrandFormController(props);
|
||||||
onSaved,
|
|
||||||
quickCreate = false,
|
|
||||||
}: {
|
|
||||||
csrfToken: string;
|
|
||||||
brand?: BrandDTO;
|
|
||||||
onSaved: (brand: BrandDTO) => void;
|
|
||||||
quickCreate?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const id = useId();
|
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 (
|
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}>
|
<fieldset className="space-y-4" disabled={mutation.isPending}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={id}>{t("name")}</Label>
|
<Label htmlFor={id}>{t("name")}</Label>
|
||||||
|
|||||||
@ -1,47 +1,28 @@
|
|||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { BrandDTO } from "../../../../common/brands";
|
import { useBrandSelectController } from "../controllers";
|
||||||
import { brandApi, brandKeys } from "../api";
|
import type { BrandSelectProps } from "../types";
|
||||||
|
|
||||||
import { BrandEditorDialog } from "./brand-editor-dialog";
|
import { BrandEditorDialog } from "./brand-editor-dialog";
|
||||||
|
|
||||||
const LEGACY = "legacy";
|
const LEGACY = "legacy";
|
||||||
export function BrandSelect({
|
export function BrandSelect(props: BrandSelectProps) {
|
||||||
id,
|
const {
|
||||||
label,
|
id,
|
||||||
brandId,
|
label,
|
||||||
legacyName,
|
brandId,
|
||||||
csrfToken,
|
legacyName,
|
||||||
disabled,
|
csrfToken,
|
||||||
required,
|
disabled,
|
||||||
allowQuickCreate,
|
required,
|
||||||
onChange,
|
allowQuickCreate,
|
||||||
}: {
|
onChange,
|
||||||
id: string;
|
} = props;
|
||||||
label: string;
|
const { query, open, setOpen, brands, legacy, options } = useBrandSelectController(props);
|
||||||
brandId: string | null;
|
|
||||||
legacyName: string;
|
|
||||||
csrfToken: string;
|
|
||||||
disabled: boolean;
|
|
||||||
required: boolean;
|
|
||||||
allowQuickCreate: boolean;
|
|
||||||
onChange: (brand: BrandDTO | null) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
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) {
|
if (legacy) {
|
||||||
options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
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 { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { z } from "zod/v4";
|
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../../../../common";
|
import { FieldTypeSchema } from "../../../../common";
|
||||||
import { AssetDefinitionSchema, FieldTypeSchema } from "../../../../common";
|
|
||||||
import type { CatalogSaved } from "../../../catalog/shared/controllers";
|
|
||||||
import { useCatalogSave } from "../../../catalog/shared/controllers";
|
|
||||||
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
||||||
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
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({
|
export function CatalogFieldForm(props: CatalogFieldFormProps) {
|
||||||
choicesText: z.string().max(10000),
|
const { scope, item } = props;
|
||||||
});
|
const { form, mutation, type, submit } = useCatalogFieldFormController(props);
|
||||||
|
|
||||||
type FieldFormValues = z.infer<typeof FieldFormSchema>;
|
|
||||||
|
|
||||||
export function CatalogFieldForm({
|
|
||||||
scope,
|
|
||||||
csrfToken,
|
|
||||||
item,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
scope: string;
|
|
||||||
csrfToken: string;
|
|
||||||
item?: CatalogItemDTO | undefined;
|
|
||||||
onSaved: CatalogSaved;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const id = useId();
|
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 (
|
return (
|
||||||
<form
|
<form className="space-y-4" onSubmit={submit}>
|
||||||
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)
|
|
||||||
: [],
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
<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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import { Label } from "@repo/shadcn-ui/components/label";
|
import { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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 { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
||||||
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
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(props: CatalogRelationFormProps) {
|
||||||
export function CatalogRelationForm({
|
const { scope, item } = props;
|
||||||
scope,
|
const { form, mutation, symmetric, submit } = useCatalogRelationFormController(props);
|
||||||
csrfToken,
|
|
||||||
item,
|
|
||||||
onSaved,
|
|
||||||
}: {
|
|
||||||
scope: string;
|
|
||||||
csrfToken: string;
|
|
||||||
item?: CatalogItemDTO | undefined;
|
|
||||||
onSaved: CatalogSaved;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const id = useId();
|
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 (
|
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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={id}>{t("name")}</Label>
|
<Label htmlFor={id}>{t("name")}</Label>
|
||||||
<Input
|
<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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||||
import { FormActions } from "@repo/shadcn-ui/components/form-dialog";
|
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 { Label } from "@repo/shadcn-ui/components/label";
|
||||||
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action";
|
||||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||||
import { useId, useState } from "react";
|
import { useId } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { useTranslation } from "react-i18next";
|
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 { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
||||||
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
||||||
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
|
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
|
||||||
|
import { useCatalogTemplateFormController } from "../controllers";
|
||||||
|
import type { CatalogTemplateFormProps } from "../types";
|
||||||
|
|
||||||
const TemplateFormSchema = AssetDefinitionSchema.options[1];
|
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||||
type TemplateFormValues = z.infer<typeof TemplateFormSchema>;
|
const { scope, csrfToken, allowQuickCreate = true } = props;
|
||||||
export function CatalogTemplateForm({
|
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
|
||||||
scope,
|
useCatalogTemplateFormController(props);
|
||||||
csrfToken,
|
|
||||||
item,
|
|
||||||
copy,
|
|
||||||
items,
|
|
||||||
onSaved,
|
|
||||||
allowQuickCreate = true,
|
|
||||||
}: {
|
|
||||||
scope: string;
|
|
||||||
csrfToken: string;
|
|
||||||
item?: CatalogItemDTO | undefined;
|
|
||||||
copy?: CatalogItemDTO | undefined;
|
|
||||||
items: readonly CatalogItemDTO[];
|
|
||||||
onSaved: CatalogSaved;
|
|
||||||
allowQuickCreate?: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const id = useId();
|
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 (
|
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="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export { AssetCatalogPage } from "./list";
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../../../../common";
|
|
||||||
import { CatalogDefinitionDialog } from "../../detail";
|
import { CatalogDefinitionDialog } from "../../detail";
|
||||||
import type { CatalogEditor } from "../../shared";
|
import { CatalogEditorDialog, CatalogError } from "../../shared";
|
||||||
import { CatalogEditorDialog, CatalogError, catalogApi, catalogKeys } from "../../shared";
|
import { useAssetCatalogPageController } from "../controllers";
|
||||||
|
import type { AssetCatalogPageProps } from "../types";
|
||||||
|
|
||||||
import { CatalogCell } from "./catalog-cell";
|
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 { 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<EntityListHeader
|
<EntityListHeader
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
export { AssetForm, AssetInventoryPage } from "./assets";
|
export { AssetForm, AssetInventoryPage } from "./assets";
|
||||||
export { BrandCatalogPage } from "./brands";
|
export { BrandCatalogPage } from "./brands";
|
||||||
export { AssetCatalogPage, catalogApi, catalogKeys } from "./catalog";
|
export { AssetCatalogPage } from "./catalog";
|
||||||
export { assetsEnglish, assetsSpanish, inventoryApi, inventoryKeys } from "./shared";
|
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 { AssetSupport } from "./support-connections";
|
||||||
export { WorkstationForm, WorkstationsPage } from "./workstations";
|
export { WorkstationForm, WorkstationsPage } from "./workstations";
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export { InventoryApiError, inventoryApi, inventoryKeys } from "./inventory-api";
|
export { inventoryApi } from "./inventory-api";
|
||||||
export { SupportApiError, supportApi, supportKeys } from "./support-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 = {
|
export const inventoryApi = {
|
||||||
async saveWorkstation(companyId: string, values: WorkstationInput, token: string, id?: string) {
|
saveWorkstation: saveWorkstation,
|
||||||
return WorkstationResponseSchema.parse(
|
link: createAssetRelationship,
|
||||||
await request(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }, token)
|
endRelationship: endAssetRelationship,
|
||||||
);
|
get: getAssetInventory,
|
||||||
},
|
save: saveAsset,
|
||||||
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)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
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 { assetsEnglish, assetsSpanish } from "./i18n";
|
||||||
|
export { inventoryKeys, supportKeys } from "./queries";
|
||||||
export { InventoryFeedback, SupportFeedback } from "./ui";
|
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