diff --git a/TODO.md b/TODO.md index 8ca552a..efd85e0 100644 --- a/TODO.md +++ b/TODO.md @@ -427,4 +427,14 @@ El arbol completo para este ejemplo de "facturas" quedaría así: └── 📄 index.ts ``` -Quiero que hagas esta reestructuración pero antes pregunta todo lo que necesites para hacer el cambio bien a la primera. \ No newline at end of file +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. + diff --git a/docs/auditoria-de-separacion-logica-del-cliente.md b/docs/auditoria-de-separacion-logica-del-cliente.md new file mode 100644 index 0000000..d053743 --- /dev/null +++ b/docs/auditoria-de-separacion-logica-del-cliente.md @@ -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. diff --git a/docs/estructura-web-por-entidades-y-operaciones.md b/docs/estructura-web-por-entidades-y-operaciones.md index d9a7724..43a5546 100644 --- a/docs/estructura-web-por-entidades-y-operaciones.md +++ b/docs/estructura-web-por-entidades-y-operaciones.md @@ -44,6 +44,15 @@ tickets/ index.ts index.ts list/ + controllers/ + use-tickets-page-controller.ts + index.ts + helpers/ + tickets-filtering.ts + index.ts + types/ + tickets-page-props.ts + index.ts ui/ tickets-page.tsx index.ts @@ -52,6 +61,15 @@ tickets/ api/ ticket-api.ts index.ts + controllers/ + use-ticket-form-controller.ts + index.ts + helpers/ + ticket-target-options.ts + index.ts + types/ + ticket-form-props.ts + index.ts ui/ ticket-form.tsx ticket-editor-dialog.tsx @@ -125,9 +143,44 @@ se abra desde una intervención; el consumidor no determina la identidad del com Situar el acceso HTTP en el ámbito más específico que corresponda a sus consumidores: `//api`, `/shared/api` o `web/shared/api`. Los clientes existentes -que abarcan varias entidades, como `identity/shared/api/identity-api.ts` y -`assets/shared/api/inventory-api.ts`, pueden permanecer compartidos. No dividir un cliente -ni alterar sus firmas, queries o contratos únicamente para reproducir el árbol de ejemplo. +que abarcan varias entidades se separan por sus operaciones reales. Por ejemplo, +`users/create/api/create-user.api.ts`, `people/shared/api/save-person.api.ts` y +`process-templates/list/api/list-process-templates.api.ts`. El transporte, errores y claves +compartidos no se duplican en cada operación. Las composiciones públicas existentes de +identidad e inventario conservan sus consumidores externos, pero los controllers del propio +módulo utilizan las funciones de su operación. No dividir clientes pequeños y cohesivos +ni alterar firmas, queries o contratos únicamente para reproducir el árbol de ejemplo. + +## Separación lógica de la operación + +El ejemplo de `customer-invoices` orienta la separación de responsabilidades, no introduce +sus dependencias, servicios ni arquitectura de persistencia en Metis Desk: + +- `ui`: JSX, traducciones de presentación, accesibilidad y adaptación de eventos del DOM. + Los modales y formularios significativos conservan su componente independiente. +- `controllers`: hooks `use-…-controller` para coordinación de formularios, queries, + mutaciones, invalidación, altas relacionadas y callbacks. Los detalles de ciclo de vida + utilizados para evitar callbacks después del desmontaje quedan privados del controller. +- `adapters`: transformación de valores de formulario a entradas de la operación cuando + exista una conversión real, como vacíos a `null` o líneas a instrucciones. No envolver + objetos idénticos ni duplicar la validación de los schemas. +- `helpers`: selección, filtrado y cálculos sin acceso a React, transporte o persistencia. + Mantener nombres que identifiquen su propósito y su entidad. +- `schemas` y `types`: validación exclusiva del formulario y contratos locales inferidos + de su schema. Los contratos HTTP siguen perteneciendo a `common`. +- `api` y `queries`: transporte validado y claves de caché separadas de la presentación. + +Un controller no es una capa nueva de negocio ni un repositorio: debe conservar la operación +existente, no ocultar efectos tras nombres genéricos. No necesita `useMemo` o `useCallback` +por defecto. Formularios pequeños y consultas simples pueden permanecer en su componente +cuando no mezclen responsabilidades significativas; un hook existente que ya encapsule +la consulta no requiere un segundo controller que solo lo envuelva. + +Los controllers, adapters y helpers no importan implementaciones de UI para acceder a +cálculos o constantes. Utilizar imports directos entre APIs cuando pasar por un índice +agregado reintroduzca la composición pública en su propio transporte. Los índices exponen +solo contratos de su responsabilidad utilizados por otras unidades, no reexports internos +de compatibilidad desde el antiguo componente. ## Índices y dependencias @@ -180,8 +233,11 @@ cuando utilice una enumeración explícita. La reorganización inicial trasladó ficheros y ajustó sus imports, sin cambiar comportamiento ni contratos. No extrajo lógica nueva a controllers, hooks, schemas o adapters; esa auditoría -se aplazó por decisión del usuario. Esa limitación describe la migración, no exime al código -nuevo de las reglas de separación de responsabilidades de `AGENTS.md`. +se aplazó por decisión del usuario. La auditoría posterior separó la lógica de formularios, +listados y coordinación en los cinco módulos, excluyendo `apps/web/src` y vendor. Su alcance, +criterios y comprobaciones se registran en [la auditoría del cliente](auditoria-de-separacion-logica-del-cliente.md). +La limitación de la migración inicial no exime al código nuevo de las reglas de separación +de responsabilidades de `AGENTS.md`. Para futuros cambios, consultar también [componentes de interfaz](componentes-de-interfaz.md), [inventario de modales](inventario-de-modales.md), [patrón de listados](patron-de-listados-de-entidades.md) diff --git a/modules/assets/src/web/asset-relationships/create/api/create-asset-relationship.api.ts b/modules/assets/src/web/asset-relationships/create/api/create-asset-relationship.api.ts new file mode 100644 index 0000000..b0e0c24 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/create/api/create-asset-relationship.api.ts @@ -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) + ); +} diff --git a/modules/assets/src/web/asset-relationships/create/api/index.ts b/modules/assets/src/web/asset-relationships/create/api/index.ts new file mode 100644 index 0000000..15e03d2 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/create/api/index.ts @@ -0,0 +1 @@ +export { createAssetRelationship } from "./create-asset-relationship.api"; diff --git a/modules/assets/src/web/asset-relationships/end/api/end-asset-relationship.api.ts b/modules/assets/src/web/asset-relationships/end/api/end-asset-relationship.api.ts new file mode 100644 index 0000000..78e3675 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/end/api/end-asset-relationship.api.ts @@ -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) + ); +} diff --git a/modules/assets/src/web/asset-relationships/end/api/index.ts b/modules/assets/src/web/asset-relationships/end/api/index.ts new file mode 100644 index 0000000..928f81c --- /dev/null +++ b/modules/assets/src/web/asset-relationships/end/api/index.ts @@ -0,0 +1 @@ +export { endAssetRelationship } from "./end-asset-relationship.api"; diff --git a/modules/assets/src/web/asset-relationships/end/controllers/index.ts b/modules/assets/src/web/asset-relationships/end/controllers/index.ts new file mode 100644 index 0000000..2e556b4 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/end/controllers/index.ts @@ -0,0 +1 @@ +export { useEndRelationshipDialogController } from "./use-end-relationship-dialog-controller"; diff --git a/modules/assets/src/web/asset-relationships/end/controllers/use-end-relationship-dialog-controller.ts b/modules/assets/src/web/asset-relationships/end/controllers/use-end-relationship-dialog-controller.ts new file mode 100644 index 0000000..52260e8 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/end/controllers/use-end-relationship-dialog-controller.ts @@ -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 }; +} diff --git a/modules/assets/src/web/asset-relationships/end/types/end-relationship-dialog-props.ts b/modules/assets/src/web/asset-relationships/end/types/end-relationship-dialog-props.ts new file mode 100644 index 0000000..ebb7740 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/end/types/end-relationship-dialog-props.ts @@ -0,0 +1,10 @@ +import type { RelationshipDTO } from "../../../../common"; + +export type EndRelationshipDialogProps = { + relationship: RelationshipDTO; + companyId: string; + csrfToken: string; + sourceName: string; + targetName: string; + onClose: () => void; +}; diff --git a/modules/assets/src/web/asset-relationships/end/types/index.ts b/modules/assets/src/web/asset-relationships/end/types/index.ts new file mode 100644 index 0000000..06b23db --- /dev/null +++ b/modules/assets/src/web/asset-relationships/end/types/index.ts @@ -0,0 +1 @@ +export type { EndRelationshipDialogProps } from "./end-relationship-dialog-props"; diff --git a/modules/assets/src/web/asset-relationships/end/ui/end-relationship-dialog.tsx b/modules/assets/src/web/asset-relationships/end/ui/end-relationship-dialog.tsx index d257df5..9bc5f7b 100644 --- a/modules/assets/src/web/asset-relationships/end/ui/end-relationship-dialog.tsx +++ b/modules/assets/src/web/asset-relationships/end/ui/end-relationship-dialog.tsx @@ -1,32 +1,15 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions, FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import type { RelationshipDTO } from "../../../../common"; -import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared"; +import { InventoryFeedback } from "../../../shared"; +import { useEndRelationshipDialogController } from "../controllers"; +import type { EndRelationshipDialogProps } from "../types"; -export function EndRelationshipDialog({ - relationship, - companyId, - csrfToken, - sourceName, - targetName, - onClose, -}: { - relationship: RelationshipDTO; - companyId: string; - csrfToken: string; - sourceName: string; - targetName: string; - onClose: () => void; -}) { +export function EndRelationshipDialog(props: EndRelationshipDialogProps) { + const { relationship, sourceName, targetName, onClose } = props; + const { mutation } = useEndRelationshipDialogController(props); const { t } = useTranslation("assets"); - const client = useQueryClient(); - const mutation = useMutation({ - mutationFn: () => inventoryApi.endRelationship(companyId, relationship.id, csrfToken), - onSuccess: () => client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }), - }); return ( getAssetInventory(companyId), + }); + const [adding, setAdding] = useState(false); + const [endingId, setEndingId] = useState(); + 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 }; +} diff --git a/modules/assets/src/web/asset-relationships/list/types/asset-relationships-props.ts b/modules/assets/src/web/asset-relationships/list/types/asset-relationships-props.ts new file mode 100644 index 0000000..a77a4ea --- /dev/null +++ b/modules/assets/src/web/asset-relationships/list/types/asset-relationships-props.ts @@ -0,0 +1,5 @@ +export type AssetRelationshipsProps = { + companyId: string; + csrfToken: string; + assetId: string; +}; diff --git a/modules/assets/src/web/asset-relationships/list/types/index.ts b/modules/assets/src/web/asset-relationships/list/types/index.ts new file mode 100644 index 0000000..88ff1c2 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/list/types/index.ts @@ -0,0 +1 @@ +export type { AssetRelationshipsProps } from "./asset-relationships-props"; diff --git a/modules/assets/src/web/asset-relationships/list/ui/asset-relationships.tsx b/modules/assets/src/web/asset-relationships/list/ui/asset-relationships.tsx index cc37d5b..2cae0d2 100644 --- a/modules/assets/src/web/asset-relationships/list/ui/asset-relationships.tsx +++ b/modules/assets/src/web/asset-relationships/list/ui/asset-relationships.tsx @@ -1,41 +1,19 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { RelationshipDTO } from "../../../../common"; -import { inventoryApi, inventoryKeys } from "../../../shared"; import { EndRelationshipDialog } from "../../end"; import { RelationshipEditorDialog } from "../../shared"; +import { useAssetRelationshipsController } from "../controllers"; +import type { AssetRelationshipsProps } from "../types"; -export function AssetRelationships({ - companyId, - csrfToken, - assetId, -}: { - companyId: string; - csrfToken: string; - assetId: string; -}) { - const { t, i18n } = useTranslation("assets"); - const query = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const [adding, setAdding] = useState(false); - const [endingId, setEndingId] = useState(); - const ending = query.data?.relationships.find((relationship) => relationship.id === endingId); - const links = (query.data?.relationships ?? []).filter( - (link) => link.values.sourceId === assetId || link.values.targetId === assetId - ); - const active = links.filter((link) => !link.endedAt); - const ended = links.filter((link) => link.endedAt); +export function AssetRelationships(props: AssetRelationshipsProps) { + const { companyId, csrfToken, assetId } = props; + const { query, adding, setAdding, setEndingId, ending, active, ended, dateOf } = + useAssetRelationshipsController(props); + const { t } = useTranslation("assets"); const nameOf = (id: string) => query.data?.assets.find((asset) => asset.id === id)?.values.name ?? id; - const dateOf = (date: string) => - new Intl.DateTimeFormat(i18n.language, { dateStyle: "medium", timeStyle: "short" }).format( - new Date(date) - ); const render = (link: RelationshipDTO) => { const source = link.values.sourceId === assetId; return ( diff --git a/modules/assets/src/web/asset-relationships/shared/controllers/index.ts b/modules/assets/src/web/asset-relationships/shared/controllers/index.ts new file mode 100644 index 0000000..df6917b --- /dev/null +++ b/modules/assets/src/web/asset-relationships/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useRelationshipFormController } from "./use-relationship-form-controller"; diff --git a/modules/assets/src/web/asset-relationships/shared/controllers/use-relationship-form-controller.ts b/modules/assets/src/web/asset-relationships/shared/controllers/use-relationship-form-controller.ts new file mode 100644 index 0000000..b4089c2 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/shared/controllers/use-relationship-form-controller.ts @@ -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) => { + 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, + }; +} diff --git a/modules/assets/src/web/asset-relationships/shared/types/index.ts b/modules/assets/src/web/asset-relationships/shared/types/index.ts new file mode 100644 index 0000000..8aa2944 --- /dev/null +++ b/modules/assets/src/web/asset-relationships/shared/types/index.ts @@ -0,0 +1 @@ +export type { RelationshipFormProps } from "./relationship-form-props"; diff --git a/modules/assets/src/web/asset-relationships/shared/types/relationship-form-props.ts b/modules/assets/src/web/asset-relationships/shared/types/relationship-form-props.ts new file mode 100644 index 0000000..47c5dcb --- /dev/null +++ b/modules/assets/src/web/asset-relationships/shared/types/relationship-form-props.ts @@ -0,0 +1,6 @@ +export type RelationshipFormProps = { + companyId: string; + csrfToken: string; + assetId: string; + onSaved: () => void; +}; diff --git a/modules/assets/src/web/asset-relationships/shared/ui/relationship-form.tsx b/modules/assets/src/web/asset-relationships/shared/ui/relationship-form.tsx index ab825d9..cb150a4 100644 --- a/modules/assets/src/web/asset-relationships/shared/ui/relationship-form.tsx +++ b/modules/assets/src/web/asset-relationships/shared/ui/relationship-form.tsx @@ -4,57 +4,37 @@ import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { RelationshipInputDTO } from "../../../../common"; -import { catalogApi, catalogKeys } from "../../../catalog"; import { CatalogRelationCreateDialog } from "../../../catalog-relations"; -import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared"; +import { InventoryFeedback } from "../../../shared"; +import { useRelationshipFormController } from "../controllers"; +import type { RelationshipFormProps } from "../types"; -export function RelationshipForm({ - companyId, - csrfToken, - assetId, - onSaved, -}: { - companyId: string; - csrfToken: string; - assetId: string; - onSaved: () => void; -}) { +export function RelationshipForm(props: RelationshipFormProps) { + const { companyId, csrfToken } = props; + const { + typeId, + setTypeId, + targetId, + setTargetId, + notes, + setNotes, + quick, + setQuick, + catalog, + inventory, + mutation, + submit, + relationshipTypes, + targetAssets, + } = useRelationshipFormController(props); const { t } = useTranslation("assets"); const id = useId(); - const client = useQueryClient(); - const [typeId, setTypeId] = useState(""); - const [targetId, setTargetId] = useState(""); - const [notes, setNotes] = useState(""); - const [quick, setQuick] = useState(false); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const mutation = useMutation({ - mutationFn: (values: RelationshipInputDTO) => inventoryApi.link(companyId, values, csrfToken), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); - onSaved(); - }, - }); return ( <> -
{ - event.preventDefault(); - mutation.mutate({ sourceId: assetId, targetId, typeId, notes }); - }} - > +
item.values.kind === "relation" && item.effectiveActive) - .map((item) => ({ value: item.id, label: item.values.name }))} + options={relationshipTypes.map((item) => ({ value: item.id, label: item.values.name }))} placeholder={t("chooseRelation")} required value={typeId} @@ -78,9 +56,7 @@ export function RelationshipForm({ id={`${id}-target`} label={t("targetAsset")} onValueChange={setTargetId} - options={(inventory.data?.assets ?? []) - .filter((asset) => asset.id !== assetId) - .map((asset) => ({ value: asset.id, label: asset.values.name }))} + options={targetAssets.map((asset) => ({ value: asset.id, label: asset.values.name }))} placeholder={t("chooseAsset")} required value={targetId} diff --git a/modules/assets/src/web/assets/detail/controllers/index.ts b/modules/assets/src/web/assets/detail/controllers/index.ts new file mode 100644 index 0000000..5b9d73f --- /dev/null +++ b/modules/assets/src/web/assets/detail/controllers/index.ts @@ -0,0 +1 @@ +export { useAssetDetailController } from "./use-asset-detail-controller"; diff --git a/modules/assets/src/web/assets/detail/controllers/use-asset-detail-controller.ts b/modules/assets/src/web/assets/detail/controllers/use-asset-detail-controller.ts new file mode 100644 index 0000000..2ab0a33 --- /dev/null +++ b/modules/assets/src/web/assets/detail/controllers/use-asset-detail-controller.ts @@ -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 }; +} diff --git a/modules/assets/src/web/assets/detail/types/asset-detail-props.ts b/modules/assets/src/web/assets/detail/types/asset-detail-props.ts new file mode 100644 index 0000000..d6996e3 --- /dev/null +++ b/modules/assets/src/web/assets/detail/types/asset-detail-props.ts @@ -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; +}; diff --git a/modules/assets/src/web/assets/detail/types/index.ts b/modules/assets/src/web/assets/detail/types/index.ts new file mode 100644 index 0000000..ebf1933 --- /dev/null +++ b/modules/assets/src/web/assets/detail/types/index.ts @@ -0,0 +1 @@ +export type { AssetDetailProps } from "./asset-detail-props"; diff --git a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx index 42f952c..8b910e6 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-detail.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-detail.tsx @@ -1,33 +1,19 @@ -import { useQuery } from "@tanstack/react-query"; -import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import type { AssetDTO } from "../../../../common/inventory"; -import { inventoryApi, inventoryKeys } from "../../../shared"; -import type { InventoryLookups } from "../../shared"; -export function AssetDetail({ - asset, - lookups, - compact = false, - children, -}: { - asset: AssetDTO; - lookups: InventoryLookups; - compact?: boolean; - children?: ReactNode; -}) { +import { useAssetDetailController } from "../controllers"; +import type { AssetDetailProps } from "../types"; + +export function AssetDetail(props: AssetDetailProps) { + const { asset, lookups, compact = false, children } = props; + const { workstations } = useAssetDetailController(props); const { t, i18n } = useTranslation("assets"); - const inventory = useQuery({ - queryKey: inventoryKeys.company(asset.companyId), - queryFn: () => inventoryApi.get(asset.companyId), - }); - const workstations = inventory.data?.workstations.filter( - (item) => - item.values.active && - (item.values.assetIds.includes(asset.id) || item.values.sharedAssetIds.includes(asset.id)) - ); - const nameOf = (id: string | null, options: readonly { id: string; name: string }[]) => - id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment"); + const nameOf = ( + id: string | null, + options: readonly { + id: string; + name: string; + }[] + ) => (id ? (options.find((option) => option.id === id)?.name ?? id) : t("noAssignment")); const display = (value: string | number | boolean | undefined) => value === undefined ? t("noValue") diff --git a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx index 9ea6ac3..ef9b2e1 100644 --- a/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx +++ b/modules/assets/src/web/assets/detail/ui/asset-selection-panel.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import type { AssetDTO } from "../../../../common/inventory"; import { AssetRelationships } from "../../../asset-relationships"; import { AssetSupport } from "../../../support-connections"; -import type { InventoryLookups } from "../../shared"; +import type { InventoryLookups } from "../../shared/types"; import { AssetDetail } from "./asset-detail"; diff --git a/modules/assets/src/web/assets/index.ts b/modules/assets/src/web/assets/index.ts index 5cf2e69..ee2a496 100644 --- a/modules/assets/src/web/assets/index.ts +++ b/modules/assets/src/web/assets/index.ts @@ -1,4 +1,4 @@ export { WorkstationAssetCreateDialog } from "./create"; export { AssetInventoryPage } from "./list"; -export type { InventoryLookups } from "./shared"; export { AssetForm } from "./shared"; +export type { InventoryLookups } from "./shared/types"; diff --git a/modules/assets/src/web/assets/list/api/get-asset-inventory.api.ts b/modules/assets/src/web/assets/list/api/get-asset-inventory.api.ts new file mode 100644 index 0000000..c1aa7eb --- /dev/null +++ b/modules/assets/src/web/assets/list/api/get-asset-inventory.api.ts @@ -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)); +} diff --git a/modules/assets/src/web/assets/list/api/index.ts b/modules/assets/src/web/assets/list/api/index.ts new file mode 100644 index 0000000..301639b --- /dev/null +++ b/modules/assets/src/web/assets/list/api/index.ts @@ -0,0 +1 @@ +export { getAssetInventory } from "./get-asset-inventory.api"; diff --git a/modules/assets/src/web/assets/list/controllers/index.ts b/modules/assets/src/web/assets/list/controllers/index.ts new file mode 100644 index 0000000..ce607c0 --- /dev/null +++ b/modules/assets/src/web/assets/list/controllers/index.ts @@ -0,0 +1 @@ +export { useAssetInventoryPageController } from "./use-inventory-page-controller"; diff --git a/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts new file mode 100644 index 0000000..98c3f8b --- /dev/null +++ b/modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts @@ -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(initialCreate ? "new" : undefined); + const [viewId, setViewId] = useState(); + const [selectedId, setSelectedId] = useState(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, + }; +} diff --git a/modules/assets/src/web/assets/list/types/index.ts b/modules/assets/src/web/assets/list/types/index.ts new file mode 100644 index 0000000..9274108 --- /dev/null +++ b/modules/assets/src/web/assets/list/types/index.ts @@ -0,0 +1 @@ +export type { AssetInventoryPageProps } from "./inventory-page-props"; diff --git a/modules/assets/src/web/assets/list/types/inventory-page-props.ts b/modules/assets/src/web/assets/list/types/inventory-page-props.ts new file mode 100644 index 0000000..f64918f --- /dev/null +++ b/modules/assets/src/web/assets/list/types/inventory-page-props.ts @@ -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; +}; diff --git a/modules/assets/src/web/assets/list/ui/inventory-page.tsx b/modules/assets/src/web/assets/list/ui/inventory-page.tsx index 06bd20a..7dbfe1b 100644 --- a/modules/assets/src/web/assets/list/ui/inventory-page.tsx +++ b/modules/assets/src/web/assets/list/ui/inventory-page.tsx @@ -6,58 +6,39 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { CatalogError, catalogApi, catalogKeys } from "../../../catalog"; -import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared"; +import { CatalogError } from "../../../catalog"; +import { InventoryFeedback } from "../../../shared"; import { AssetDetailDialog, AssetSelectionPanel } from "../../detail"; -import type { InventoryLookups } from "../../shared"; import { AssetEditorDialog } from "../../shared"; +import { useAssetInventoryPageController } from "../controllers"; +import type { AssetInventoryPageProps } from "../types"; -export function AssetInventoryPage({ - companyId, - csrfToken, - lookups, - initialId, - initialCreate = false, -}: { - companyId: string; - csrfToken: string; - lookups: InventoryLookups; - initialId?: string | undefined; - initialCreate?: boolean | undefined; -}) { +function selectedRowClass(id: string, selectedId: string | undefined, detailOpen: boolean) { + return id === selectedId && detailOpen + ? "bg-accent hover:bg-accent [&>[data-pinned=right]]:bg-accent [&>[data-pinned=right]]:group-hover/entity-row:bg-accent [&>[data-pinned=right]]:group-has-aria-expanded/entity-row:bg-accent" + : ""; +} +export function AssetInventoryPage(props: AssetInventoryPageProps) { + const { companyId, csrfToken, lookups } = props; + const { + query, + catalog, + search, + setSearch, + editor, + setEditor, + setViewId, + setSelectedId, + detailOpen, + setDetailOpen, + selected, + edited, + viewed, + filtered, + } = useAssetInventoryPageController(props); const { t, i18n } = useTranslation("assets"); - const query = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - }); - const [search, setSearch] = useState(""); - const [editor, setEditor] = useState(initialCreate ? "new" : undefined); - const [viewId, setViewId] = useState(); - const [selectedId, setSelectedId] = useState(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 (
); } - -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" - : ""; -} diff --git a/modules/assets/src/web/assets/shared/api/index.ts b/modules/assets/src/web/assets/shared/api/index.ts new file mode 100644 index 0000000..0ebf8c8 --- /dev/null +++ b/modules/assets/src/web/assets/shared/api/index.ts @@ -0,0 +1 @@ +export { saveAsset } from "./save-asset.api"; diff --git a/modules/assets/src/web/assets/shared/api/save-asset.api.ts b/modules/assets/src/web/assets/shared/api/save-asset.api.ts new file mode 100644 index 0000000..1dde312 --- /dev/null +++ b/modules/assets/src/web/assets/shared/api/save-asset.api.ts @@ -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) + ); +} diff --git a/modules/assets/src/web/assets/shared/controllers/index.ts b/modules/assets/src/web/assets/shared/controllers/index.ts new file mode 100644 index 0000000..00d6d47 --- /dev/null +++ b/modules/assets/src/web/assets/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useAssetFormController } from "./use-asset-form-controller"; diff --git a/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts new file mode 100644 index 0000000..4bea8ab --- /dev/null +++ b/modules/assets/src/web/assets/shared/controllers/use-asset-form-controller.ts @@ -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({ + 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"; diff --git a/modules/assets/src/web/assets/shared/index.ts b/modules/assets/src/web/assets/shared/index.ts index 82bfe19..8cf0501 100644 --- a/modules/assets/src/web/assets/shared/index.ts +++ b/modules/assets/src/web/assets/shared/index.ts @@ -1,2 +1,2 @@ -export type { InventoryLookups } from "./ui"; +export type { InventoryLookups } from "./types"; export { AssetEditorDialog, AssetForm } from "./ui"; diff --git a/modules/assets/src/web/assets/shared/inventory.test.tsx b/modules/assets/src/web/assets/shared/inventory.test.tsx index 1a78db3..08bb058 100644 --- a/modules/assets/src/web/assets/shared/inventory.test.tsx +++ b/modules/assets/src/web/assets/shared/inventory.test.tsx @@ -14,10 +14,11 @@ import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate"; import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed"; import type { RelationshipDTO } from "../../../common"; import { AssetRelationships } from "../../asset-relationships"; -import { catalogKeys } from "../../catalog"; +import { catalogKeys } from "../../catalog/shared/api"; import { CatalogRelationForm } from "../../catalog-relations"; import { CatalogTemplateForm } from "../../catalog-templates"; -import { assetsEnglish, assetsSpanish, inventoryKeys } from "../../shared"; +import { assetsEnglish, assetsSpanish } from "../../shared"; +import { inventoryKeys } from "../../shared/queries"; import { WorkstationForm, WorkstationsPage } from "../../workstations"; import { AssetInventoryPage } from "../list"; diff --git a/modules/assets/src/web/assets/shared/types/asset-form-props.ts b/modules/assets/src/web/assets/shared/types/asset-form-props.ts new file mode 100644 index 0000000..bf251d8 --- /dev/null +++ b/modules/assets/src/web/assets/shared/types/asset-form-props.ts @@ -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; +}; diff --git a/modules/assets/src/web/assets/shared/types/index.ts b/modules/assets/src/web/assets/shared/types/index.ts new file mode 100644 index 0000000..fca7c39 --- /dev/null +++ b/modules/assets/src/web/assets/shared/types/index.ts @@ -0,0 +1 @@ +export type { AssetFormProps, InventoryLookups } from "./asset-form-props"; diff --git a/modules/assets/src/web/assets/shared/ui/asset-form.tsx b/modules/assets/src/web/assets/shared/ui/asset-form.tsx index 2e860d4..b162d5a 100644 --- a/modules/assets/src/web/assets/shared/ui/asset-form.tsx +++ b/modules/assets/src/web/assets/shared/ui/asset-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -6,118 +5,43 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CatalogItemDTO } from "../../../../common"; -import type { AssetDTO, AssetInput } from "../../../../common/inventory"; -import { AssetInputSchema, AssetStatusSchema } from "../../../../common/inventory"; +import { AssetStatusSchema } from "../../../../common/inventory"; import { BrandSelect } from "../../../brands"; import { CatalogTemplateCreateDialog } from "../../../catalog-templates"; -import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared"; +import { InventoryFeedback } from "../../../shared"; +import { useAssetFormController } from "../controllers"; +import type { AssetFormProps } from "../types"; import { AssetCharacteristicControl } from "./asset-characteristic-control"; -export type InventoryLookups = { - sites: readonly { id: string; name: string }[]; - people: readonly { id: string; name: string; active: boolean }[]; - onCreateSite: (onCreated: (id: string) => void) => void; - onCreatePerson: (onCreated: (id: string) => void) => void; -}; - -export function AssetForm({ - companyId, - csrfToken, - asset, - items, - lookups, - onSaved, - allowQuickCreate = true, -}: { - companyId: string; - csrfToken: string; - asset?: AssetDTO | undefined; - items: readonly CatalogItemDTO[]; - lookups: InventoryLookups; - onSaved: (id: string) => void; - allowQuickCreate?: boolean; -}) { +export function AssetForm(props: AssetFormProps) { + const { companyId, csrfToken, asset, items, lookups, allowQuickCreate = true } = props; + const { + creatingTemplate, + setCreatingTemplate, + form, + values, + brandFieldId, + templates, + fields, + mutation, + setCharacteristic, + submit, + selectTemplate, + changeStatus, + templateCreated, + siteCreated, + personCreated, + } = useAssetFormController(props); const { t } = useTranslation("assets"); const prefix = useId(); - const client = useQueryClient(); - const [creatingTemplate, setCreatingTemplate] = useState(false); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const form = useForm({ - resolver: zodResolver(AssetInputSchema), - defaultValues: asset?.values ?? { - name: "", - templateId: "", - siteId: null, - personId: null, - status: "available", - notes: "", - characteristics: {}, - }, - }); - const values = form.watch(); - const brandFieldId = items.find((item) => item.referenceCatalog === "brands")?.id; - const templates = items.filter((item) => item.values.kind === "template" && item.effectiveActive); - const template = templates.find((item) => item.id === values.templateId); - const fields = - asset?.specification.fields ?? - (template?.values.kind === "template" - ? template.values.fields.flatMap((binding) => { - const field = items.find((item) => item.id === binding.fieldId); - return field?.values.kind === "field" - ? [ - { - fieldId: field.id, - name: field.values.name, - type: field.values.type, - unit: field.values.unit, - choices: field.values.choices, - required: binding.required, - }, - ] - : []; - }) - : []); - const mutation = useMutation({ - mutationFn: (input: AssetInput) => inventoryApi.save(companyId, input, csrfToken, asset?.id), - onSuccess: async (response) => { - await Promise.all([ - client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }), - client.invalidateQueries({ queryKey: ["remote-connections", companyId] }), - ]); - if (mounted.current) { - onSaved(response.asset.id); - } - }, - }); - const setCharacteristic = ( - fieldId: string, - value: AssetInput["characteristics"][string] | undefined - ) => { - const characteristics = { ...form.getValues("characteristics") }; - if (value === undefined) { - delete characteristics[fieldId]; - } else { - characteristics[fieldId] = value; - } - form.setValue("characteristics", characteristics, { shouldDirty: true }); - }; const selectProps = { disabled: mutation.isPending, placeholder: t("noAssignment") }; return ( <> - mutation.mutate(input))}> +
@@ -135,11 +59,7 @@ export function AssetForm({ disabled={!!asset || mutation.isPending} id={`${prefix}-template`} label={t("template")} - onValueChange={(templateId) => { - form.setValue("templateId", templateId); - form.setValue("characteristics", {}); - form.setValue("brandId", null); - }} + onValueChange={selectTemplate} options={ asset ? [{ value: asset.values.templateId, label: asset.specification.templateName }] @@ -156,12 +76,7 @@ export function AssetForm({ disabled={mutation.isPending} id={`${prefix}-status`} label={t("status")} - onValueChange={(value) => { - const parsed = AssetStatusSchema.safeParse(value); - if (parsed.success) { - form.setValue("status", parsed.data); - } - }} + onValueChange={changeStatus} options={AssetStatusSchema.options.map((value) => ({ value, label: t(value) }))} placeholder={t("chooseValue")} required @@ -176,12 +91,7 @@ export function AssetForm({ allowQuickCreate ? { label: t("newSite"), - onClick: () => - lookups.onCreateSite((id) => { - if (mounted.current) { - form.setValue("siteId", id); - } - }), + onClick: () => lookups.onCreateSite(siteCreated), } : undefined } @@ -200,12 +110,7 @@ export function AssetForm({ allowQuickCreate ? { label: t("newPerson"), - onClick: () => - lookups.onCreatePerson((id) => { - if (mounted.current) { - form.setValue("personId", id); - } - }), + onClick: () => lookups.onCreatePerson(personCreated), } : undefined } @@ -301,12 +206,7 @@ export function AssetForm({ setCreatingTemplate(false); } }} - onSaved={(id) => { - form.setValue("templateId", id); - form.setValue("characteristics", {}); - form.setValue("brandId", null); - setCreatingTemplate(false); - }} + onSaved={templateCreated} scope={companyId} /> )} diff --git a/modules/assets/src/web/assets/shared/ui/index.ts b/modules/assets/src/web/assets/shared/ui/index.ts index 45f774c..945f9cf 100644 --- a/modules/assets/src/web/assets/shared/ui/index.ts +++ b/modules/assets/src/web/assets/shared/ui/index.ts @@ -1,3 +1,2 @@ export { AssetEditorDialog } from "./asset-editor-dialog"; -export type { InventoryLookups } from "./asset-form"; export { AssetForm } from "./asset-form"; diff --git a/modules/assets/src/web/brands/list/controllers/index.ts b/modules/assets/src/web/brands/list/controllers/index.ts new file mode 100644 index 0000000..a1ab2bf --- /dev/null +++ b/modules/assets/src/web/brands/list/controllers/index.ts @@ -0,0 +1 @@ +export { useBrandCatalogPageController } from "./use-brands-page-controller"; diff --git a/modules/assets/src/web/brands/list/controllers/use-brands-page-controller.ts b/modules/assets/src/web/brands/list/controllers/use-brands-page-controller.ts new file mode 100644 index 0000000..c055289 --- /dev/null +++ b/modules/assets/src/web/brands/list/controllers/use-brands-page-controller.ts @@ -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(); + 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 }; +} diff --git a/modules/assets/src/web/brands/list/types/brands-page-props.ts b/modules/assets/src/web/brands/list/types/brands-page-props.ts new file mode 100644 index 0000000..2cb3414 --- /dev/null +++ b/modules/assets/src/web/brands/list/types/brands-page-props.ts @@ -0,0 +1,3 @@ +export type BrandCatalogPageProps = { + csrfToken: string; +}; diff --git a/modules/assets/src/web/brands/list/types/index.ts b/modules/assets/src/web/brands/list/types/index.ts new file mode 100644 index 0000000..2013a74 --- /dev/null +++ b/modules/assets/src/web/brands/list/types/index.ts @@ -0,0 +1 @@ +export type { BrandCatalogPageProps } from "./brands-page-props"; diff --git a/modules/assets/src/web/brands/list/ui/brands-page.tsx b/modules/assets/src/web/brands/list/ui/brands-page.tsx index 2a5ef75..107e773 100644 --- a/modules/assets/src/web/brands/list/ui/brands-page.tsx +++ b/modules/assets/src/web/brands/list/ui/brands-page.tsx @@ -7,23 +7,19 @@ import { EntityTable, } from "@repo/shadcn-ui/components/entity-list"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useQuery } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { BrandDTO } from "../../../../common/brands"; -import { BrandEditorDialog, brandApi, brandKeys } from "../../shared"; +import { BrandEditorDialog } from "../../shared"; +import { useBrandCatalogPageController } from "../controllers"; +import type { BrandCatalogPageProps } from "../types"; -export function BrandCatalogPage({ csrfToken }: { csrfToken: string }) { +export function BrandCatalogPage(props: BrandCatalogPageProps) { + const { csrfToken } = props; + const { query, edit, setEdit, open, setOpen, status, setStatus, items } = + useBrandCatalogPageController(); const { t, i18n } = useTranslation("assets"); const id = useId(); - const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list }); - const [edit, setEdit] = useState(); - const [open, setOpen] = useState(false); - const [status, setStatus] = useState(""); - const items = (query.data?.brands ?? []).filter( - (brand) => !status || brand.active === (status === "active") - ); return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const client = useQueryClient(); + const form = useForm({ + 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 }; +} diff --git a/modules/assets/src/web/brands/shared/controllers/use-brand-select-controller.ts b/modules/assets/src/web/brands/shared/controllers/use-brand-select-controller.ts new file mode 100644 index 0000000..cbfe17e --- /dev/null +++ b/modules/assets/src/web/brands/shared/controllers/use-brand-select-controller.ts @@ -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 }; +} diff --git a/modules/assets/src/web/brands/shared/types/brand-form-props.ts b/modules/assets/src/web/brands/shared/types/brand-form-props.ts new file mode 100644 index 0000000..5977d7d --- /dev/null +++ b/modules/assets/src/web/brands/shared/types/brand-form-props.ts @@ -0,0 +1,8 @@ +import type { BrandDTO } from "../../../../common/brands"; + +export type BrandFormProps = { + csrfToken: string; + brand?: BrandDTO; + onSaved: (brand: BrandDTO) => void; + quickCreate?: boolean; +}; diff --git a/modules/assets/src/web/brands/shared/types/brand-select-props.ts b/modules/assets/src/web/brands/shared/types/brand-select-props.ts new file mode 100644 index 0000000..8f44988 --- /dev/null +++ b/modules/assets/src/web/brands/shared/types/brand-select-props.ts @@ -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; +}; diff --git a/modules/assets/src/web/brands/shared/types/index.ts b/modules/assets/src/web/brands/shared/types/index.ts new file mode 100644 index 0000000..edfdb06 --- /dev/null +++ b/modules/assets/src/web/brands/shared/types/index.ts @@ -0,0 +1,2 @@ +export type { BrandFormProps } from "./brand-form-props"; +export type { BrandSelectProps } from "./brand-select-props"; diff --git a/modules/assets/src/web/brands/shared/ui/brand-form.tsx b/modules/assets/src/web/brands/shared/ui/brand-form.tsx index 1db43d3..1c82032 100644 --- a/modules/assets/src/web/brands/shared/ui/brand-form.tsx +++ b/modules/assets/src/web/brands/shared/ui/brand-form.tsx @@ -1,61 +1,22 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { BrandDTO, BrandInput } from "../../../../common/brands"; -import { BrandInputSchema } from "../../../../common/brands"; -import { BrandApiError, brandApi, brandKeys } from "../api"; +import { BrandApiError } from "../api"; +import { useBrandFormController } from "../controllers"; +import type { BrandFormProps } from "../types"; -export function BrandForm({ - csrfToken, - brand, - onSaved, - quickCreate = false, -}: { - csrfToken: string; - brand?: BrandDTO; - onSaved: (brand: BrandDTO) => void; - quickCreate?: boolean; -}) { +export function BrandForm(props: BrandFormProps) { + const { brand, quickCreate = false } = props; + const { form, mutation, submit } = useBrandFormController(props); const { t } = useTranslation("assets"); const id = useId(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const client = useQueryClient(); - const form = useForm({ - 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 ( - mutation.mutate(values))}> +
diff --git a/modules/assets/src/web/brands/shared/ui/brand-select.tsx b/modules/assets/src/web/brands/shared/ui/brand-select.tsx index 0f887c6..44a67ff 100644 --- a/modules/assets/src/web/brands/shared/ui/brand-select.tsx +++ b/modules/assets/src/web/brands/shared/ui/brand-select.tsx @@ -1,47 +1,28 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { BrandDTO } from "../../../../common/brands"; -import { brandApi, brandKeys } from "../api"; +import { useBrandSelectController } from "../controllers"; +import type { BrandSelectProps } from "../types"; import { BrandEditorDialog } from "./brand-editor-dialog"; const LEGACY = "legacy"; -export function BrandSelect({ - id, - label, - brandId, - legacyName, - csrfToken, - disabled, - required, - allowQuickCreate, - onChange, -}: { - id: string; - label: string; - brandId: string | null; - legacyName: string; - csrfToken: string; - disabled: boolean; - required: boolean; - allowQuickCreate: boolean; - onChange: (brand: BrandDTO | null) => void; -}) { +export function BrandSelect(props: BrandSelectProps) { + const { + id, + label, + brandId, + legacyName, + csrfToken, + disabled, + required, + allowQuickCreate, + onChange, + } = props; + const { query, open, setOpen, brands, legacy, options } = useBrandSelectController(props); const { t } = useTranslation("assets"); - const query = useQuery({ queryKey: brandKeys.all, queryFn: brandApi.list }); - const [open, setOpen] = useState(false); - const brands = (query.data?.brands ?? []).filter((brand) => brand.active || brand.id === brandId); - // Un valor anterior permanece visible sin asociarlo o sobrescribirlo por una coincidencia de nombre. - const legacy = !brandId && legacyName !== ""; - const options = brands.map((brand) => ({ - value: brand.id, - label: brand.name + (brand.active ? "" : ` (${t("brands.inactive")})`), - })); if (legacy) { options.unshift({ value: LEGACY, label: `${legacyName} (${t("brands.legacy")})` }); } diff --git a/modules/assets/src/web/catalog-fields/shared/controllers/index.ts b/modules/assets/src/web/catalog-fields/shared/controllers/index.ts new file mode 100644 index 0000000..6775829 --- /dev/null +++ b/modules/assets/src/web/catalog-fields/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useCatalogFieldFormController } from "./use-catalog-form-controller"; diff --git a/modules/assets/src/web/catalog-fields/shared/controllers/use-catalog-form-controller.ts b/modules/assets/src/web/catalog-fields/shared/controllers/use-catalog-form-controller.ts new file mode 100644 index 0000000..08bbda4 --- /dev/null +++ b/modules/assets/src/web/catalog-fields/shared/controllers/use-catalog-form-controller.ts @@ -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({ + 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 }; +} diff --git a/modules/assets/src/web/catalog-fields/shared/schemas/catalog-form.schema.ts b/modules/assets/src/web/catalog-fields/shared/schemas/catalog-form.schema.ts new file mode 100644 index 0000000..c0bf45d --- /dev/null +++ b/modules/assets/src/web/catalog-fields/shared/schemas/catalog-form.schema.ts @@ -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), +}); diff --git a/modules/assets/src/web/catalog-fields/shared/schemas/index.ts b/modules/assets/src/web/catalog-fields/shared/schemas/index.ts new file mode 100644 index 0000000..666d485 --- /dev/null +++ b/modules/assets/src/web/catalog-fields/shared/schemas/index.ts @@ -0,0 +1 @@ +export { FieldFormSchema } from "./catalog-form.schema"; diff --git a/modules/assets/src/web/catalog-fields/shared/types/catalog-form-props.ts b/modules/assets/src/web/catalog-fields/shared/types/catalog-form-props.ts new file mode 100644 index 0000000..840378b --- /dev/null +++ b/modules/assets/src/web/catalog-fields/shared/types/catalog-form-props.ts @@ -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; + +export type CatalogFieldFormProps = { + scope: string; + csrfToken: string; + item?: CatalogItemDTO | undefined; + onSaved: CatalogSaved; +}; diff --git a/modules/assets/src/web/catalog-fields/shared/types/index.ts b/modules/assets/src/web/catalog-fields/shared/types/index.ts new file mode 100644 index 0000000..9554795 --- /dev/null +++ b/modules/assets/src/web/catalog-fields/shared/types/index.ts @@ -0,0 +1 @@ +export type { CatalogFieldFormProps, FieldFormValues } from "./catalog-form-props"; diff --git a/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx b/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx index 0d99d7b..6d501d8 100644 --- a/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx +++ b/modules/assets/src/web/catalog-fields/shared/ui/catalog-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; @@ -6,66 +5,21 @@ import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { CatalogItemDTO } from "../../../../common"; -import { AssetDefinitionSchema, FieldTypeSchema } from "../../../../common"; -import type { CatalogSaved } from "../../../catalog/shared/controllers"; -import { useCatalogSave } from "../../../catalog/shared/controllers"; +import { FieldTypeSchema } from "../../../../common"; import { CatalogError } from "../../../catalog/shared/ui/catalog-error"; import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help"; +import { useCatalogFieldFormController } from "../controllers"; +import type { CatalogFieldFormProps } from "../types"; -const FieldFormSchema = AssetDefinitionSchema.options[0].omit({ choices: true }).extend({ - choicesText: z.string().max(10000), -}); - -type FieldFormValues = z.infer; - -export function CatalogFieldForm({ - scope, - csrfToken, - item, - onSaved, -}: { - scope: string; - csrfToken: string; - item?: CatalogItemDTO | undefined; - onSaved: CatalogSaved; -}) { +export function CatalogFieldForm(props: CatalogFieldFormProps) { + const { scope, item } = props; + const { form, mutation, type, submit } = useCatalogFieldFormController(props); const { t } = useTranslation("assets"); const id = useId(); - const current = item?.values.kind === "field" ? item.values : undefined; - const form = useForm({ - 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 ( - - mutation.mutate({ - ...values, - choices: - values.type === "choice" - ? choicesText - .split(/\r?\n/) - .map((value) => value.trim()) - .filter(Boolean) - : [], - }) - )} - > +
diff --git a/modules/assets/src/web/catalog-relations/shared/controllers/index.ts b/modules/assets/src/web/catalog-relations/shared/controllers/index.ts new file mode 100644 index 0000000..4db34ce --- /dev/null +++ b/modules/assets/src/web/catalog-relations/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useCatalogRelationFormController } from "./use-catalog-relation-form-controller"; diff --git a/modules/assets/src/web/catalog-relations/shared/controllers/use-catalog-relation-form-controller.ts b/modules/assets/src/web/catalog-relations/shared/controllers/use-catalog-relation-form-controller.ts new file mode 100644 index 0000000..80af1c0 --- /dev/null +++ b/modules/assets/src/web/catalog-relations/shared/controllers/use-catalog-relation-form-controller.ts @@ -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>({ + 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 }; +} diff --git a/modules/assets/src/web/catalog-relations/shared/schemas/catalog-relation-form.schema.ts b/modules/assets/src/web/catalog-relations/shared/schemas/catalog-relation-form.schema.ts new file mode 100644 index 0000000..340ea45 --- /dev/null +++ b/modules/assets/src/web/catalog-relations/shared/schemas/catalog-relation-form.schema.ts @@ -0,0 +1,3 @@ +import { AssetDefinitionSchema } from "../../../../common"; + +export const schema = AssetDefinitionSchema.options[2]; diff --git a/modules/assets/src/web/catalog-relations/shared/schemas/index.ts b/modules/assets/src/web/catalog-relations/shared/schemas/index.ts new file mode 100644 index 0000000..606a913 --- /dev/null +++ b/modules/assets/src/web/catalog-relations/shared/schemas/index.ts @@ -0,0 +1 @@ +export { schema } from "./catalog-relation-form.schema"; diff --git a/modules/assets/src/web/catalog-relations/shared/types/catalog-relation-form-props.ts b/modules/assets/src/web/catalog-relations/shared/types/catalog-relation-form-props.ts new file mode 100644 index 0000000..1a832e1 --- /dev/null +++ b/modules/assets/src/web/catalog-relations/shared/types/catalog-relation-form-props.ts @@ -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; +}; diff --git a/modules/assets/src/web/catalog-relations/shared/types/index.ts b/modules/assets/src/web/catalog-relations/shared/types/index.ts new file mode 100644 index 0000000..f0b12a3 --- /dev/null +++ b/modules/assets/src/web/catalog-relations/shared/types/index.ts @@ -0,0 +1 @@ +export type { CatalogRelationFormProps } from "./catalog-relation-form-props"; diff --git a/modules/assets/src/web/catalog-relations/shared/ui/catalog-relation-form.tsx b/modules/assets/src/web/catalog-relations/shared/ui/catalog-relation-form.tsx index 8333b17..9867298 100644 --- a/modules/assets/src/web/catalog-relations/shared/ui/catalog-relation-form.tsx +++ b/modules/assets/src/web/catalog-relations/shared/ui/catalog-relation-form.tsx @@ -1,50 +1,23 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { z } from "zod/v4"; -import type { CatalogItemDTO } from "../../../../common"; -import { AssetDefinitionSchema } from "../../../../common"; -import type { CatalogSaved } from "../../../catalog/shared/controllers"; -import { useCatalogSave } from "../../../catalog/shared/controllers"; import { CatalogError } from "../../../catalog/shared/ui/catalog-error"; import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help"; +import { useCatalogRelationFormController } from "../controllers"; +import type { CatalogRelationFormProps } from "../types"; -const schema = AssetDefinitionSchema.options[2]; -export function CatalogRelationForm({ - scope, - csrfToken, - item, - onSaved, -}: { - scope: string; - csrfToken: string; - item?: CatalogItemDTO | undefined; - onSaved: CatalogSaved; -}) { +export function CatalogRelationForm(props: CatalogRelationFormProps) { + const { scope, item } = props; + const { form, mutation, symmetric, submit } = useCatalogRelationFormController(props); const { t } = useTranslation("assets"); const id = useId(); - const current = item?.values.kind === "relation" ? item.values : undefined; - const form = useForm>({ - 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 ( - mutation.mutate(values))}> +
({ + 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 }; +} diff --git a/modules/assets/src/web/catalog-templates/shared/schemas/catalog-template-form.schema.ts b/modules/assets/src/web/catalog-templates/shared/schemas/catalog-template-form.schema.ts new file mode 100644 index 0000000..7e2bf31 --- /dev/null +++ b/modules/assets/src/web/catalog-templates/shared/schemas/catalog-template-form.schema.ts @@ -0,0 +1,3 @@ +import { AssetDefinitionSchema } from "../../../../common"; + +export const TemplateFormSchema = AssetDefinitionSchema.options[1]; diff --git a/modules/assets/src/web/catalog-templates/shared/schemas/index.ts b/modules/assets/src/web/catalog-templates/shared/schemas/index.ts new file mode 100644 index 0000000..8f0a8e1 --- /dev/null +++ b/modules/assets/src/web/catalog-templates/shared/schemas/index.ts @@ -0,0 +1 @@ +export { TemplateFormSchema } from "./catalog-template-form.schema"; diff --git a/modules/assets/src/web/catalog-templates/shared/types/catalog-template-form-props.ts b/modules/assets/src/web/catalog-templates/shared/types/catalog-template-form-props.ts new file mode 100644 index 0000000..f009f9e --- /dev/null +++ b/modules/assets/src/web/catalog-templates/shared/types/catalog-template-form-props.ts @@ -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; + +export type CatalogTemplateFormProps = { + scope: string; + csrfToken: string; + item?: CatalogItemDTO | undefined; + copy?: CatalogItemDTO | undefined; + items: readonly CatalogItemDTO[]; + onSaved: CatalogSaved; + allowQuickCreate?: boolean; +}; diff --git a/modules/assets/src/web/catalog-templates/shared/types/index.ts b/modules/assets/src/web/catalog-templates/shared/types/index.ts new file mode 100644 index 0000000..d7f9b03 --- /dev/null +++ b/modules/assets/src/web/catalog-templates/shared/types/index.ts @@ -0,0 +1 @@ +export type { CatalogTemplateFormProps, TemplateFormValues } from "./catalog-template-form-props"; diff --git a/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx b/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx index d5d5079..0cfef5a 100644 --- a/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx +++ b/modules/assets/src/web/catalog-templates/shared/ui/catalog-template-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -6,66 +5,24 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { z } from "zod/v4"; -import type { CatalogItemDTO } from "../../../../common"; -import { AssetDefinitionSchema } from "../../../../common"; -import type { CatalogSaved } from "../../../catalog/shared/controllers"; -import { useCatalogSave } from "../../../catalog/shared/controllers"; import { CatalogError } from "../../../catalog/shared/ui/catalog-error"; import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help"; import { CatalogFieldCreateDialog } from "../../../catalog-fields"; +import { useCatalogTemplateFormController } from "../controllers"; +import type { CatalogTemplateFormProps } from "../types"; -const TemplateFormSchema = AssetDefinitionSchema.options[1]; -type TemplateFormValues = z.infer; -export function CatalogTemplateForm({ - scope, - csrfToken, - item, - copy, - items, - onSaved, - allowQuickCreate = true, -}: { - scope: string; - csrfToken: string; - item?: CatalogItemDTO | undefined; - copy?: CatalogItemDTO | undefined; - items: readonly CatalogItemDTO[]; - onSaved: CatalogSaved; - allowQuickCreate?: boolean; -}) { +export function CatalogTemplateForm(props: CatalogTemplateFormProps) { + const { scope, csrfToken, allowQuickCreate = true } = props; + const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } = + useCatalogTemplateFormController(props); const { t } = useTranslation("assets"); const id = useId(); - const [creatingField, setCreatingField] = useState(false); - const source = item ?? copy; - const current = source?.values.kind === "template" ? source.values : undefined; - const form = useForm({ - 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 ( <> - mutation.mutate(values))}> +
diff --git a/modules/assets/src/web/catalog/index.ts b/modules/assets/src/web/catalog/index.ts index 73c1b88..bd06f96 100644 --- a/modules/assets/src/web/catalog/index.ts +++ b/modules/assets/src/web/catalog/index.ts @@ -1,2 +1,3 @@ export { AssetCatalogPage } from "./list"; -export { CatalogError, catalogApi, catalogKeys } from "./shared"; +export { CatalogError } from "./shared"; +export { catalogApi, catalogKeys } from "./shared/api"; diff --git a/modules/assets/src/web/catalog/list/controllers/index.ts b/modules/assets/src/web/catalog/list/controllers/index.ts new file mode 100644 index 0000000..f0a7c7a --- /dev/null +++ b/modules/assets/src/web/catalog/list/controllers/index.ts @@ -0,0 +1 @@ +export { useAssetCatalogPageController } from "./use-catalog-page-controller"; diff --git a/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts b/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts new file mode 100644 index 0000000..918d5ef --- /dev/null +++ b/modules/assets/src/web/catalog/list/controllers/use-catalog-page-controller.ts @@ -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(); + const [editor, setEditor] = useState(); + 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, + }; +} diff --git a/modules/assets/src/web/catalog/list/types/catalog-page-props.ts b/modules/assets/src/web/catalog/list/types/catalog-page-props.ts new file mode 100644 index 0000000..57ac9f9 --- /dev/null +++ b/modules/assets/src/web/catalog/list/types/catalog-page-props.ts @@ -0,0 +1,4 @@ +export type AssetCatalogPageProps = { + scope: string; + csrfToken: string; +}; diff --git a/modules/assets/src/web/catalog/list/types/index.ts b/modules/assets/src/web/catalog/list/types/index.ts new file mode 100644 index 0000000..06067b1 --- /dev/null +++ b/modules/assets/src/web/catalog/list/types/index.ts @@ -0,0 +1 @@ +export type { AssetCatalogPageProps } from "./catalog-page-props"; diff --git a/modules/assets/src/web/catalog/list/ui/catalog-page.tsx b/modules/assets/src/web/catalog/list/ui/catalog-page.tsx index 8f0afc1..1a67509 100644 --- a/modules/assets/src/web/catalog/list/ui/catalog-page.tsx +++ b/modules/assets/src/web/catalog/list/ui/catalog-page.tsx @@ -1,42 +1,31 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CatalogItemDTO } from "../../../../common"; import { CatalogDefinitionDialog } from "../../detail"; -import type { CatalogEditor } from "../../shared"; -import { CatalogEditorDialog, CatalogError, catalogApi, catalogKeys } from "../../shared"; +import { CatalogEditorDialog, CatalogError } from "../../shared"; +import { useAssetCatalogPageController } from "../controllers"; +import type { AssetCatalogPageProps } from "../types"; import { CatalogCell } from "./catalog-cell"; -export function AssetCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) { +export function AssetCatalogPage(props: AssetCatalogPageProps) { + const { scope, csrfToken } = props; + const { + tab, + setTab, + search, + setSearch, + setViewId, + editor, + setEditor, + query, + mutation, + items, + view, + visible, + } = useAssetCatalogPageController(props); const { t, i18n } = useTranslation("assets"); - const client = useQueryClient(); - const [tab, setTab] = useState<"field" | "template" | "relation">("template"); - const [search, setSearch] = useState(""); - const [viewId, setViewId] = useState(); - const [editor, setEditor] = useState(); - 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 (
["inventory", id] }; -export class InventoryApiError extends Error { - constructor(public readonly code: string) { - super(code); - } -} -async function request(path: string, body?: unknown, csrfToken?: string) { - const response = await fetch(`/api/assets/${path}`, { - method: body === undefined ? "GET" : "POST", - credentials: "same-origin", - headers: - body === undefined - ? {} - : { "content-type": "application/json", "x-csrf-token": csrfToken ?? "" }, - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - }); - const data: unknown = await response.json(); - if (!response.ok) { - const error = z.object({ code: z.string() }).safeParse(data); - throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR"); - } - return data; -} export const inventoryApi = { - async saveWorkstation(companyId: string, values: WorkstationInput, token: string, id?: string) { - return WorkstationResponseSchema.parse( - await request(`${companyId}/workstations${id ? `/${id}/update` : ""}`, { values }, token) - ); - }, - async link(companyId: string, values: RelationshipInputDTO, token: string) { - return RelationshipResponseSchema.parse( - await request(`${companyId}/relationships`, { values }, token) - ); - }, - async endRelationship(companyId: string, id: string, token: string) { - return RelationshipResponseSchema.parse( - await request(`${companyId}/relationships/${id}/end`, {}, token) - ); - }, - async get(companyId: string) { - return InventoryResponseSchema.parse(await request(companyId)); - }, - async save(companyId: string, values: AssetInput, token: string, id?: string) { - return AssetResponseSchema.parse( - await request(`${companyId}${id ? `/${id}/update` : ""}`, { values }, token) - ); - }, + saveWorkstation: saveWorkstation, + link: createAssetRelationship, + endRelationship: endAssetRelationship, + get: getAssetInventory, + save: saveAsset, }; diff --git a/modules/assets/src/web/shared/api/inventory-request.ts b/modules/assets/src/web/shared/api/inventory-request.ts new file mode 100644 index 0000000..77a705c --- /dev/null +++ b/modules/assets/src/web/shared/api/inventory-request.ts @@ -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; +} diff --git a/modules/assets/src/web/shared/api/support-api-error.ts b/modules/assets/src/web/shared/api/support-api-error.ts new file mode 100644 index 0000000..19ab224 --- /dev/null +++ b/modules/assets/src/web/shared/api/support-api-error.ts @@ -0,0 +1,5 @@ +export class SupportApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/assets/src/web/shared/api/support-api.ts b/modules/assets/src/web/shared/api/support-api.ts deleted file mode 100644 index c4af60f..0000000 --- a/modules/assets/src/web/shared/api/support-api.ts +++ /dev/null @@ -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) - ); - }, -}; diff --git a/modules/assets/src/web/shared/api/support-request.ts b/modules/assets/src/web/shared/api/support-request.ts new file mode 100644 index 0000000..f2a1b28 --- /dev/null +++ b/modules/assets/src/web/shared/api/support-request.ts @@ -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; +} diff --git a/modules/assets/src/web/shared/index.ts b/modules/assets/src/web/shared/index.ts index 060e96a..6970a45 100644 --- a/modules/assets/src/web/shared/index.ts +++ b/modules/assets/src/web/shared/index.ts @@ -1,3 +1,4 @@ -export { inventoryApi, inventoryKeys, supportApi, supportKeys } from "./api"; +export { inventoryApi } from "./api"; export { assetsEnglish, assetsSpanish } from "./i18n"; +export { inventoryKeys, supportKeys } from "./queries"; export { InventoryFeedback, SupportFeedback } from "./ui"; diff --git a/modules/assets/src/web/shared/queries/index.ts b/modules/assets/src/web/shared/queries/index.ts new file mode 100644 index 0000000..519e80d --- /dev/null +++ b/modules/assets/src/web/shared/queries/index.ts @@ -0,0 +1,2 @@ +export { inventoryKeys } from "./inventory-query-keys"; +export { supportKeys } from "./support-query-keys"; diff --git a/modules/assets/src/web/shared/queries/inventory-query-keys.ts b/modules/assets/src/web/shared/queries/inventory-query-keys.ts new file mode 100644 index 0000000..b52bad3 --- /dev/null +++ b/modules/assets/src/web/shared/queries/inventory-query-keys.ts @@ -0,0 +1 @@ +export const inventoryKeys = { company: (id: string) => ["inventory", id] }; diff --git a/modules/assets/src/web/shared/queries/support-query-keys.ts b/modules/assets/src/web/shared/queries/support-query-keys.ts new file mode 100644 index 0000000..1fe3025 --- /dev/null +++ b/modules/assets/src/web/shared/queries/support-query-keys.ts @@ -0,0 +1,4 @@ +export const supportKeys = { + asset: (companyId: string, assetId: string) => ["asset-support", companyId, assetId], + tools: ["support-tools"], +}; diff --git a/modules/assets/src/web/support-connections/list/api/get-support-connections.api.ts b/modules/assets/src/web/support-connections/list/api/get-support-connections.api.ts new file mode 100644 index 0000000..7c4b95d --- /dev/null +++ b/modules/assets/src/web/support-connections/list/api/get-support-connections.api.ts @@ -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}`)); +} diff --git a/modules/assets/src/web/support-connections/list/api/index.ts b/modules/assets/src/web/support-connections/list/api/index.ts new file mode 100644 index 0000000..53e7db6 --- /dev/null +++ b/modules/assets/src/web/support-connections/list/api/index.ts @@ -0,0 +1 @@ +export { getSupportConnections } from "./get-support-connections.api"; diff --git a/modules/assets/src/web/support-connections/list/controllers/index.ts b/modules/assets/src/web/support-connections/list/controllers/index.ts new file mode 100644 index 0000000..843af79 --- /dev/null +++ b/modules/assets/src/web/support-connections/list/controllers/index.ts @@ -0,0 +1 @@ +export { useAssetSupportController } from "./use-asset-support-controller"; diff --git a/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts b/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts new file mode 100644 index 0000000..9b44a04 --- /dev/null +++ b/modules/assets/src/web/support-connections/list/controllers/use-asset-support-controller.ts @@ -0,0 +1,38 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { SupportConnectionDTO } from "../../../../common/support"; +import { supportKeys } from "../../../shared/queries"; +import { getSupportConnections } from "../api"; +import type { AssetSupportProps } from "../types"; + +export function useAssetSupportController({ companyId, assetId }: AssetSupportProps) { + const query = useQuery({ + queryKey: supportKeys.asset(companyId, assetId), + queryFn: () => getSupportConnections(companyId, assetId), + }); + const [open, setOpen] = useState(false); + const [edit, setEdit] = useState(); + const [toolsOpen, setToolsOpen] = useState(false); + const [showRetired, setShowRetired] = useState(false); + const [historyId, setHistoryId] = useState(); + const history = query.data?.connections.find((connection) => connection.id === historyId); + const items = + query.data?.connections.filter((item) => showRetired || item.values.state !== "retired") ?? []; + const tools = query.data?.tools ?? []; + return { + query, + open, + setOpen, + edit, + setEdit, + toolsOpen, + setToolsOpen, + showRetired, + setShowRetired, + setHistoryId, + history, + items, + tools, + }; +} diff --git a/modules/assets/src/web/support-connections/list/types/asset-support-props.ts b/modules/assets/src/web/support-connections/list/types/asset-support-props.ts new file mode 100644 index 0000000..c9cf01e --- /dev/null +++ b/modules/assets/src/web/support-connections/list/types/asset-support-props.ts @@ -0,0 +1,6 @@ +export type AssetSupportProps = { + companyId: string; + assetId: string; + csrfToken: string; + retired?: boolean; +}; diff --git a/modules/assets/src/web/support-connections/list/types/index.ts b/modules/assets/src/web/support-connections/list/types/index.ts new file mode 100644 index 0000000..6815552 --- /dev/null +++ b/modules/assets/src/web/support-connections/list/types/index.ts @@ -0,0 +1 @@ +export type { AssetSupportProps } from "./asset-support-props"; diff --git a/modules/assets/src/web/support-connections/list/ui/asset-support.tsx b/modules/assets/src/web/support-connections/list/ui/asset-support.tsx index 9e73b92..205f023 100644 --- a/modules/assets/src/web/support-connections/list/ui/asset-support.tsx +++ b/modules/assets/src/web/support-connections/list/ui/asset-support.tsx @@ -1,40 +1,31 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { SupportConnectionDTO } from "../../../../common/support"; -import { supportApi, supportKeys } from "../../../shared"; import { SupportToolsDialog } from "../../../support-tools"; import { SupportSummary } from "../../detail"; import { SupportHistoryDialog } from "../../history"; import { SupportConnectionEditorDialog } from "../../shared"; +import { useAssetSupportController } from "../controllers"; +import type { AssetSupportProps } from "../types"; -export function AssetSupport({ - companyId, - assetId, - csrfToken, - retired = false, -}: { - companyId: string; - assetId: string; - csrfToken: string; - retired?: boolean; -}) { +export function AssetSupport(props: AssetSupportProps) { + const { companyId, assetId, csrfToken, retired = false } = props; + const { + query, + open, + setOpen, + edit, + setEdit, + toolsOpen, + setToolsOpen, + showRetired, + setShowRetired, + setHistoryId, + history, + items, + tools, + } = useAssetSupportController(props); const { t } = useTranslation("assets"); - const query = useQuery({ - queryKey: supportKeys.asset(companyId, assetId), - queryFn: () => supportApi.get(companyId, assetId), - }); - const [open, setOpen] = useState(false); - const [edit, setEdit] = useState(); - const [toolsOpen, setToolsOpen] = useState(false); - const [showRetired, setShowRetired] = useState(false); - const [historyId, setHistoryId] = useState(); - const history = query.data?.connections.find((connection) => connection.id === historyId); - const items = - query.data?.connections.filter((item) => showRetired || item.values.state !== "retired") ?? []; - const tools = query.data?.tools ?? []; return (
diff --git a/modules/assets/src/web/support-connections/shared/api/index.ts b/modules/assets/src/web/support-connections/shared/api/index.ts new file mode 100644 index 0000000..d3acb1d --- /dev/null +++ b/modules/assets/src/web/support-connections/shared/api/index.ts @@ -0,0 +1 @@ +export { saveSupportConnection } from "./save-support-connection.api"; diff --git a/modules/assets/src/web/support-connections/shared/api/save-support-connection.api.ts b/modules/assets/src/web/support-connections/shared/api/save-support-connection.api.ts new file mode 100644 index 0000000..25dc317 --- /dev/null +++ b/modules/assets/src/web/support-connections/shared/api/save-support-connection.api.ts @@ -0,0 +1,15 @@ +import type { SupportInput } from "../../../../common/support"; +import { SupportResponseSchema } from "../../../../common/support"; +import { supportRequest } from "../../../shared/api/support-request"; + +export async function saveSupportConnection( + companyId: string, + assetId: string, + values: SupportInput, + token: string, + id?: string +) { + return SupportResponseSchema.parse( + await supportRequest(`${companyId}/${assetId}${id ? `/${id}/update` : ""}`, values, token) + ); +} diff --git a/modules/assets/src/web/support-connections/shared/controllers/index.ts b/modules/assets/src/web/support-connections/shared/controllers/index.ts new file mode 100644 index 0000000..1bc1403 --- /dev/null +++ b/modules/assets/src/web/support-connections/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useSupportFormController } from "./use-support-form-controller"; diff --git a/modules/assets/src/web/support-connections/shared/controllers/use-support-form-controller.ts b/modules/assets/src/web/support-connections/shared/controllers/use-support-form-controller.ts new file mode 100644 index 0000000..a264dfd --- /dev/null +++ b/modules/assets/src/web/support-connections/shared/controllers/use-support-form-controller.ts @@ -0,0 +1,43 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { SupportInput } from "../../../../common/support"; +import { SupportValuesSchema } from "../../../../common/support"; +import { supportKeys } from "../../../shared/queries"; +import { saveSupportConnection } from "../api"; +import type { SupportFormProps } from "../types"; + +export function useSupportFormController({ + companyId, + assetId, + csrfToken, + connection, + onSaved, +}: SupportFormProps) { + const client = useQueryClient(); + const [quick, setQuick] = useState(false); + const form = useForm({ + resolver: zodResolver(SupportValuesSchema), + defaultValues: connection?.values ?? { + toolId: "", + destination: "", + requiresVpn: false, + preferred: false, + state: "pending", + notes: "", + }, + }); + const values = form.watch(); + const mutation = useMutation({ + mutationFn: (input: SupportInput) => + saveSupportConnection(companyId, assetId, input, csrfToken, connection?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: supportKeys.asset(companyId, assetId) }); + onSaved(); + }, + }); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + return { quick, setQuick, form, values, mutation, submit }; +} diff --git a/modules/assets/src/web/support-connections/shared/types/index.ts b/modules/assets/src/web/support-connections/shared/types/index.ts new file mode 100644 index 0000000..a8bdcdc --- /dev/null +++ b/modules/assets/src/web/support-connections/shared/types/index.ts @@ -0,0 +1 @@ +export type { SupportFormProps } from "./support-form-props"; diff --git a/modules/assets/src/web/support-connections/shared/types/support-form-props.ts b/modules/assets/src/web/support-connections/shared/types/support-form-props.ts new file mode 100644 index 0000000..6c12297 --- /dev/null +++ b/modules/assets/src/web/support-connections/shared/types/support-form-props.ts @@ -0,0 +1,10 @@ +import type { SupportConnectionDTO, SupportToolDTO } from "../../../../common/support"; + +export type SupportFormProps = { + companyId: string; + assetId: string; + csrfToken: string; + tools: readonly SupportToolDTO[]; + connection?: SupportConnectionDTO; + onSaved: () => void; +}; diff --git a/modules/assets/src/web/support-connections/shared/ui/support-form.tsx b/modules/assets/src/web/support-connections/shared/ui/support-form.tsx index 1b02ef4..a478b0a 100644 --- a/modules/assets/src/web/support-connections/shared/ui/support-form.tsx +++ b/modules/assets/src/web/support-connections/shared/ui/support-form.tsx @@ -1,66 +1,26 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { - SupportConnectionDTO, - SupportInput, - SupportToolDTO, -} from "../../../../common/support"; -import { SupportValuesSchema } from "../../../../common/support"; -import { SupportFeedback, supportApi, supportKeys } from "../../../shared"; +import { SupportFeedback } from "../../../shared"; import { SupportToolEditorDialog } from "../../../support-tools"; +import { useSupportFormController } from "../controllers"; +import type { SupportFormProps } from "../types"; import { SupportHelp } from "./support-help"; -export function SupportForm({ - companyId, - assetId, - csrfToken, - tools, - connection, - onSaved, -}: { - companyId: string; - assetId: string; - csrfToken: string; - tools: readonly SupportToolDTO[]; - connection?: SupportConnectionDTO; - onSaved: () => void; -}) { + +export function SupportForm(props: SupportFormProps) { + const { csrfToken, tools, connection } = props; + const { quick, setQuick, form, values, mutation, submit } = useSupportFormController(props); const { t } = useTranslation("assets"); const prefix = useId(); - const client = useQueryClient(); - const [quick, setQuick] = useState(false); - const form = useForm({ - resolver: zodResolver(SupportValuesSchema), - defaultValues: connection?.values ?? { - toolId: "", - destination: "", - requiresVpn: false, - preferred: false, - state: "pending", - notes: "", - }, - }); - const values = form.watch(); - const mutation = useMutation({ - mutationFn: (input: SupportInput) => - supportApi.save(companyId, assetId, input, csrfToken, connection?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: supportKeys.asset(companyId, assetId) }); - onSaved(); - }, - }); return ( <> - mutation.mutate(input))}> +
diff --git a/modules/assets/src/web/support-tools/list/api/index.ts b/modules/assets/src/web/support-tools/list/api/index.ts new file mode 100644 index 0000000..3ac32b6 --- /dev/null +++ b/modules/assets/src/web/support-tools/list/api/index.ts @@ -0,0 +1 @@ +export { listSupportTools } from "./list-support-tools.api"; diff --git a/modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts b/modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts new file mode 100644 index 0000000..2d906f9 --- /dev/null +++ b/modules/assets/src/web/support-tools/list/api/list-support-tools.api.ts @@ -0,0 +1,6 @@ +import { SupportToolsSchema } from "../../../../common/support"; +import { supportRequest } from "../../../shared/api/support-request"; + +export async function listSupportTools() { + return SupportToolsSchema.parse(await supportRequest("tools")); +} diff --git a/modules/assets/src/web/support-tools/list/controllers/index.ts b/modules/assets/src/web/support-tools/list/controllers/index.ts new file mode 100644 index 0000000..18ca562 --- /dev/null +++ b/modules/assets/src/web/support-tools/list/controllers/index.ts @@ -0,0 +1 @@ +export { useSupportToolsController } from "./use-support-tools-controller"; diff --git a/modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts b/modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts new file mode 100644 index 0000000..9ffc959 --- /dev/null +++ b/modules/assets/src/web/support-tools/list/controllers/use-support-tools-controller.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { SupportToolDTO } from "../../../../common/support"; +import { supportKeys } from "../../../shared/queries"; +import { listSupportTools } from "../api"; + +export function useSupportToolsController() { + const query = useQuery({ queryKey: supportKeys.tools, queryFn: () => listSupportTools() }); + const [edit, setEdit] = useState(); + const [open, setOpen] = useState(false); + return { query, edit, setEdit, open, setOpen }; +} diff --git a/modules/assets/src/web/support-tools/list/types/index.ts b/modules/assets/src/web/support-tools/list/types/index.ts new file mode 100644 index 0000000..0d65640 --- /dev/null +++ b/modules/assets/src/web/support-tools/list/types/index.ts @@ -0,0 +1 @@ +export type { SupportToolsProps } from "./support-tools-props"; diff --git a/modules/assets/src/web/support-tools/list/types/support-tools-props.ts b/modules/assets/src/web/support-tools/list/types/support-tools-props.ts new file mode 100644 index 0000000..2fce8e5 --- /dev/null +++ b/modules/assets/src/web/support-tools/list/types/support-tools-props.ts @@ -0,0 +1,3 @@ +export type SupportToolsProps = { + csrfToken: string; +}; diff --git a/modules/assets/src/web/support-tools/list/ui/support-tools.tsx b/modules/assets/src/web/support-tools/list/ui/support-tools.tsx index b22292a..0281e8a 100644 --- a/modules/assets/src/web/support-tools/list/ui/support-tools.tsx +++ b/modules/assets/src/web/support-tools/list/ui/support-tools.tsx @@ -6,18 +6,16 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { SupportToolDTO } from "../../../../common/support"; -import { supportApi, supportKeys } from "../../../shared"; import { SupportToolEditorDialog } from "../../shared"; -export function SupportTools({ csrfToken }: { csrfToken: string }) { +import { useSupportToolsController } from "../controllers"; +import type { SupportToolsProps } from "../types"; + +export function SupportTools(props: SupportToolsProps) { + const { csrfToken } = props; + const { query, edit, setEdit, open, setOpen } = useSupportToolsController(); const { t, i18n } = useTranslation("assets"); - const query = useQuery({ queryKey: supportKeys.tools, queryFn: () => supportApi.tools() }); - const [edit, setEdit] = useState(); - const [open, setOpen] = useState(false); return (
({ + resolver: zodResolver(SupportToolInputSchema), + defaultValues: tool ? { name: tool.name, active: tool.active } : { name: "", active: true }, + }); + const mutation = useMutation({ + mutationFn: (values: SupportToolInput) => saveSupportTool(values, csrfToken, tool?.id), + onSuccess: async (response) => { + await Promise.all([ + client.invalidateQueries({ queryKey: supportKeys.tools }), + client.invalidateQueries({ queryKey: ["asset-support"] }), + ]); + onSaved(response.tool.id); + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/assets/src/web/support-tools/shared/types/index.ts b/modules/assets/src/web/support-tools/shared/types/index.ts new file mode 100644 index 0000000..7e076ef --- /dev/null +++ b/modules/assets/src/web/support-tools/shared/types/index.ts @@ -0,0 +1 @@ +export type { SupportToolFormProps } from "./support-tool-form-props"; diff --git a/modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts b/modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts new file mode 100644 index 0000000..64b2378 --- /dev/null +++ b/modules/assets/src/web/support-tools/shared/types/support-tool-form-props.ts @@ -0,0 +1,7 @@ +import type { SupportToolDTO } from "../../../../common/support"; + +export type SupportToolFormProps = { + csrfToken: string; + tool?: SupportToolDTO; + onSaved: (id: string) => void; +}; diff --git a/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx b/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx index 83f5f34..a1496ae 100644 --- a/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx +++ b/modules/assets/src/web/support-tools/shared/ui/support-tool-form.tsx @@ -1,45 +1,22 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { SupportToolDTO, SupportToolInput } from "../../../../common/support"; -import { SupportToolInputSchema } from "../../../../common/support"; -import { SupportFeedback, supportApi, supportKeys } from "../../../shared"; +import { SupportFeedback } from "../../../shared"; import { SupportHelp } from "../../../support-connections/shared/ui/support-help"; -export function SupportToolForm({ - csrfToken, - tool, - onSaved, -}: { - csrfToken: string; - tool?: SupportToolDTO; - onSaved: (id: string) => void; -}) { +import { useSupportToolFormController } from "../controllers"; +import type { SupportToolFormProps } from "../types"; + +export function SupportToolForm(props: SupportToolFormProps) { + const { tool } = props; + const { form, mutation, submit } = useSupportToolFormController(props); const { t } = useTranslation("assets"); const id = useId(); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver(SupportToolInputSchema), - defaultValues: tool ? { name: tool.name, active: tool.active } : { name: "", active: true }, - }); - const mutation = useMutation({ - mutationFn: (values: SupportToolInput) => supportApi.saveTool(values, csrfToken, tool?.id), - onSuccess: async (response) => { - await Promise.all([ - client.invalidateQueries({ queryKey: supportKeys.tools }), - client.invalidateQueries({ queryKey: ["asset-support"] }), - ]); - onSaved(response.tool.id); - }, - }); return ( - mutation.mutate(values))}> +
diff --git a/modules/assets/src/web/workstations/list/controllers/index.ts b/modules/assets/src/web/workstations/list/controllers/index.ts new file mode 100644 index 0000000..26c2442 --- /dev/null +++ b/modules/assets/src/web/workstations/list/controllers/index.ts @@ -0,0 +1 @@ +export { useWorkstationsPageController } from "./use-workstations-page-controller"; diff --git a/modules/assets/src/web/workstations/list/controllers/use-workstations-page-controller.ts b/modules/assets/src/web/workstations/list/controllers/use-workstations-page-controller.ts new file mode 100644 index 0000000..328db4a --- /dev/null +++ b/modules/assets/src/web/workstations/list/controllers/use-workstations-page-controller.ts @@ -0,0 +1,48 @@ +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 { WorkstationsPageProps } from "../types"; + +export function useWorkstationsPageController({ companyId, lookups }: WorkstationsPageProps) { + const { t } = useTranslation("assets"); + const [search, setSearch] = useState(""); + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const query = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => getAssetInventory(companyId), + }); + const stations = query.data?.workstations ?? []; + const assets = query.data?.assets ?? []; + const edited = stations.find((item) => item.id === editor); + const viewed = stations.find((item) => item.id === view); + const personName = (id: string | null) => + lookups.people.find((item) => item.id === id)?.name ?? t("noAssignment"); + const siteName = (id: string | null) => + lookups.sites.find((item) => item.id === id)?.name ?? t("noAssignment"); + const members = (ids: readonly string[], empty = "noWorkstationAssets") => + ids.map((id) => assets.find((item) => item.id === id)?.values.name ?? id).join(" · ") || + t(empty); + const filtered = stations.filter((item) => + [item.values.name, personName(item.values.personId), siteName(item.values.siteId)].some( + (value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ) + ); + return { + search, + setSearch, + editor, + setEditor, + setView, + query, + edited, + viewed, + personName, + siteName, + members, + filtered, + }; +} diff --git a/modules/assets/src/web/workstations/list/types/index.ts b/modules/assets/src/web/workstations/list/types/index.ts new file mode 100644 index 0000000..8f87468 --- /dev/null +++ b/modules/assets/src/web/workstations/list/types/index.ts @@ -0,0 +1 @@ +export type { WorkstationsPageProps } from "./workstations-page-props"; diff --git a/modules/assets/src/web/workstations/list/types/workstations-page-props.ts b/modules/assets/src/web/workstations/list/types/workstations-page-props.ts new file mode 100644 index 0000000..a036ab1 --- /dev/null +++ b/modules/assets/src/web/workstations/list/types/workstations-page-props.ts @@ -0,0 +1,7 @@ +import type { InventoryLookups } from "../../../assets/shared/types"; + +export type WorkstationsPageProps = { + companyId: string; + csrfToken: string; + lookups: InventoryLookups; +}; diff --git a/modules/assets/src/web/workstations/list/ui/workstations-page.tsx b/modules/assets/src/web/workstations/list/ui/workstations-page.tsx index 3c4cf5e..476cb02 100644 --- a/modules/assets/src/web/workstations/list/ui/workstations-page.tsx +++ b/modules/assets/src/web/workstations/list/ui/workstations-page.tsx @@ -6,47 +6,31 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { InventoryLookups } from "../../../assets"; -import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared"; +import { InventoryFeedback } from "../../../shared"; import { WorkstationHistoryDialog } from "../../history"; import { WorkstationEditorDialog } from "../../shared"; -export function WorkstationsPage({ - companyId, - csrfToken, - lookups, -}: { - companyId: string; - csrfToken: string; - lookups: InventoryLookups; -}) { +import { useWorkstationsPageController } from "../controllers"; +import type { WorkstationsPageProps } from "../types"; + +export function WorkstationsPage(props: WorkstationsPageProps) { + const { companyId, csrfToken, lookups } = props; + const { + search, + setSearch, + editor, + setEditor, + setView, + query, + edited, + viewed, + personName, + siteName, + members, + filtered, + } = useWorkstationsPageController(props); const { t, i18n } = useTranslation("assets"); - const [search, setSearch] = useState(""); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const query = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const stations = query.data?.workstations ?? []; - const assets = query.data?.assets ?? []; - const edited = stations.find((item) => item.id === editor); - const viewed = stations.find((item) => item.id === view); - const personName = (id: string | null) => - lookups.people.find((item) => item.id === id)?.name ?? t("noAssignment"); - const siteName = (id: string | null) => - lookups.sites.find((item) => item.id === id)?.name ?? t("noAssignment"); - const members = (ids: readonly string[], empty = "noWorkstationAssets") => - ids.map((id) => assets.find((item) => item.id === id)?.values.name ?? id).join(" · ") || - t(empty); - const filtered = stations.filter((item) => - [item.values.name, personName(item.values.personId), siteName(item.values.siteId)].some( - (value) => value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ) - ); return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(WorkstationInputSchema), + defaultValues: workstation?.values ?? { + name: "", + active: true, + siteId: null, + personId: null, + assetIds: [], + sharedAssetIds: [], + }, + }); + const values = form.watch(); + const [quick, setQuick] = useState(false); + const [quickField, setQuickField] = useState<"assetIds" | "sharedAssetIds">("assetIds"); + const inventory = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => getAssetInventory(companyId), + }); + const catalog = useQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const assets = inventory.data?.assets ?? []; + const stations = inventory.data?.workstations ?? []; + const occupied = new Set( + stations + .filter((item) => item.values.active && item.id !== workstation?.id) + .flatMap((item) => item.values.assetIds) + ); + const sharedUsed = new Set( + stations + .filter((item) => item.values.active && item.id !== workstation?.id) + .flatMap((item) => item.values.sharedAssetIds) + ); + const unavailable = (field: "assetIds" | "sharedAssetIds", id: string) => + values.active && (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id))); + const add = (field: "assetIds" | "sharedAssetIds", id: string) => { + if ( + id && + mounted.current && + ![...form.getValues("assetIds"), ...form.getValues("sharedAssetIds")].includes(id) + ) { + form.setValue(field, [...form.getValues(field), id]); + } + }; + const mutation = useMutation({ + mutationFn: (input: WorkstationInput) => + saveWorkstation(companyId, input, csrfToken, workstation?.id), + onSuccess: async (response) => { + await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); + if (mounted.current) { + onSaved(response.workstation.id); + } + }, + }); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const siteCreated = (id: string) => { + if (mounted.current) { + form.setValue("siteId", id); + } + }; + const personCreated = (id: string) => { + if (mounted.current) { + form.setValue("personId", id); + } + }; + return { + form, + values, + quick, + setQuick, + quickField, + setQuickField, + inventory, + catalog, + assets, + unavailable, + add, + mutation, + submit, + siteCreated, + personCreated, + }; +} diff --git a/modules/assets/src/web/workstations/shared/types/index.ts b/modules/assets/src/web/workstations/shared/types/index.ts new file mode 100644 index 0000000..02c115c --- /dev/null +++ b/modules/assets/src/web/workstations/shared/types/index.ts @@ -0,0 +1 @@ +export type { WorkstationFormProps } from "./workstation-form-props"; diff --git a/modules/assets/src/web/workstations/shared/types/workstation-form-props.ts b/modules/assets/src/web/workstations/shared/types/workstation-form-props.ts new file mode 100644 index 0000000..e8569fc --- /dev/null +++ b/modules/assets/src/web/workstations/shared/types/workstation-form-props.ts @@ -0,0 +1,10 @@ +import type { WorkstationDTO } from "../../../../common"; +import type { InventoryLookups } from "../../../assets/shared/types"; + +export type WorkstationFormProps = { + companyId: string; + csrfToken: string; + lookups: InventoryLookups; + workstation?: WorkstationDTO | undefined; + onSaved: (id: string) => void; +}; diff --git a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx index ac8ff36..a41e2ae 100644 --- a/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx +++ b/modules/assets/src/web/workstations/shared/ui/workstation-form.tsx @@ -1,102 +1,41 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { WorkstationDTO, WorkstationInput } from "../../../../common"; -import { WorkstationInputSchema } from "../../../../common"; -import type { InventoryLookups } from "../../../assets"; import { WorkstationAssetCreateDialog } from "../../../assets"; -import { catalogApi, catalogKeys } from "../../../catalog"; -import { InventoryFeedback, inventoryApi, inventoryKeys } from "../../../shared"; -export function WorkstationForm({ - companyId, - csrfToken, - lookups, - workstation, - onSaved, -}: { - companyId: string; - csrfToken: string; - lookups: InventoryLookups; - workstation?: WorkstationDTO | undefined; - onSaved: (id: string) => void; -}) { +import { InventoryFeedback } from "../../../shared"; +import { useWorkstationFormController } from "../controllers"; +import type { WorkstationFormProps } from "../types"; + +export function WorkstationForm(props: WorkstationFormProps) { + const { companyId, csrfToken, lookups, workstation } = props; + const { + form, + values, + quick, + setQuick, + quickField, + setQuickField, + inventory, + catalog, + assets, + unavailable, + add, + mutation, + submit, + siteCreated, + personCreated, + } = useWorkstationFormController(props); const { t } = useTranslation("assets"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const form = useForm({ - resolver: zodResolver(WorkstationInputSchema), - defaultValues: workstation?.values ?? { - name: "", - active: true, - siteId: null, - personId: null, - assetIds: [], - sharedAssetIds: [], - }, - }); - const values = form.watch(); - const [quick, setQuick] = useState(false); - const [quickField, setQuickField] = useState<"assetIds" | "sharedAssetIds">("assetIds"); - const inventory = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - }); - const assets = inventory.data?.assets ?? []; - const stations = inventory.data?.workstations ?? []; - const occupied = new Set( - stations - .filter((item) => item.values.active && item.id !== workstation?.id) - .flatMap((item) => item.values.assetIds) - ); - const sharedUsed = new Set( - stations - .filter((item) => item.values.active && item.id !== workstation?.id) - .flatMap((item) => item.values.sharedAssetIds) - ); - const unavailable = (field: "assetIds" | "sharedAssetIds", id: string) => - values.active && (occupied.has(id) || (field === "assetIds" && sharedUsed.has(id))); - const add = (field: "assetIds" | "sharedAssetIds", id: string) => { - if ( - id && - mounted.current && - ![...form.getValues("assetIds"), ...form.getValues("sharedAssetIds")].includes(id) - ) { - form.setValue(field, [...form.getValues(field), id]); - } - }; - const mutation = useMutation({ - mutationFn: (input: WorkstationInput) => - inventoryApi.saveWorkstation(companyId, input, csrfToken, workstation?.id), - onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: inventoryKeys.company(companyId) }); - if (mounted.current) { - onSaved(response.workstation.id); - } - }, - }); return ( <> - mutation.mutate(input))}> +
@@ -112,12 +51,7 @@ export function WorkstationForm({ - lookups.onCreateSite((id) => { - if (mounted.current) { - form.setValue("siteId", id); - } - }), + onClick: () => lookups.onCreateSite(siteCreated), }} id={`${prefix}-site`} label={t("site")} @@ -132,12 +66,7 @@ export function WorkstationForm({ - lookups.onCreatePerson((id) => { - if (mounted.current) { - form.setValue("personId", id); - } - }), + onClick: () => lookups.onCreatePerson(personCreated), }} id={`${prefix}-person`} label={t("workstationPerson")} diff --git a/modules/companies/package.json b/modules/companies/package.json index 69687a0..99e1424 100644 --- a/modules/companies/package.json +++ b/modules/companies/package.json @@ -10,7 +10,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/api/domain/ticket.test.ts src/web/tickets/shared/tickets.test.tsx src/api/domain/intervention-template.test.ts src/web/intervention-templates/shared/templates.test.tsx src/api/domain/intervention.test.ts src/web/interventions/shared/interventions.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts" }, "dependencies": { "@hookform/resolvers": "5.2.2", diff --git a/modules/companies/src/web/access/create/controllers/index.ts b/modules/companies/src/web/access/create/controllers/index.ts new file mode 100644 index 0000000..1e1d342 --- /dev/null +++ b/modules/companies/src/web/access/create/controllers/index.ts @@ -0,0 +1,2 @@ +export { useAccessQuickEditorController } from "./use-access-quick-editor-controller"; +export { useQuickCreateDialogController } from "./use-quick-create-dialog-controller"; diff --git a/modules/companies/src/web/access/create/controllers/use-access-quick-editor-controller.ts b/modules/companies/src/web/access/create/controllers/use-access-quick-editor-controller.ts new file mode 100644 index 0000000..87611e3 --- /dev/null +++ b/modules/companies/src/web/access/create/controllers/use-access-quick-editor-controller.ts @@ -0,0 +1,37 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +import type { AccessFieldsDTO } from "../../../../common"; +import { getAccess } from "../../list/api"; +import { accessKeys } from "../../shared/queries"; +import type { AccessQuickEditorProps } from "../types"; + +export function useAccessQuickEditorController({ + plan, + scope, + onCreated, + onClose, +}: AccessQuickEditorProps) { + const active = useRef(true); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + }; + }, []); + const close = () => { + active.current = false; + onClose(); + }; + const created = (id?: string, values?: AccessFieldsDTO) => { + if (active.current) { + onCreated(id, values); + } + }; + const globalQuery = useQuery({ + queryKey: accessKeys.scope("global"), + queryFn: () => getAccess("global"), + enabled: plan.type === "access" && plan.scope === "global" && scope !== "global", + }); + return { close, created, globalQuery }; +} diff --git a/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts b/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts new file mode 100644 index 0000000..cbf4d39 --- /dev/null +++ b/modules/companies/src/web/access/create/controllers/use-quick-create-dialog-controller.ts @@ -0,0 +1,70 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRef } from "react"; +import { useForm } from "react-hook-form"; + +import { AccessFieldsSchema } from "../../../../common"; +import { AccessApiError, saveAccess } from "../../shared/api"; +import { accessKeys } from "../../shared/queries"; +import { matches } from "../helpers"; +import type { QuickCreateDialogProps } from "../types"; + +export function useQuickCreateDialogController({ + target, + scope, + csrfToken, + onCreated, + items, + onClose, +}: QuickCreateDialogProps) { + const client = useQueryClient(); + const dismissed = useRef(false); + const form = useForm<{ + name: string; + }>({ defaultValues: { name: "" } }); + const enteredName = form.watch("name").trim().toLocaleLowerCase(); + const existing = items.find((item) => { + const values = item.values; + return ( + "name" in values && + "active" in values && + values.active && + values.name.toLocaleLowerCase() === enteredName && + (target.kind === "system" + ? values.kind === "system" + : values.kind === "environment" && values.systemId === target.systemId) + ); + }); + const mutation = useMutation({ + mutationFn: async ({ name }: { name: string }) => { + const values = AccessFieldsSchema.parse( + target.kind === "system" + ? { kind: "system", name, active: true } + : { kind: "environment", name, active: true, systemId: target.systemId } + ); + const result = await saveAccess(scope, values, undefined, csrfToken); + const created = result.items.find( + (item) => + item.companyId === (scope === "global" ? null : scope) && matches(item.values, values) + ); + if (!created) { + throw new AccessApiError("INTERNAL_ERROR"); + } + return { result, id: created.id }; + }, + onSuccess: async ({ result, id }) => { + await client.invalidateQueries({ queryKey: ["access"] }); + client.setQueryData(accessKeys.scope(scope), result); + if (!dismissed.current) { + onCreated(id); + } + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + const changeOpen = (open: boolean) => { + if (!open) { + dismissed.current = true; + onClose(); + } + }; + return { form, existing, mutation, submit, changeOpen }; +} diff --git a/modules/companies/src/web/access/create/helpers/index.ts b/modules/companies/src/web/access/create/helpers/index.ts index e988574..84b56a9 100644 --- a/modules/companies/src/web/access/create/helpers/index.ts +++ b/modules/companies/src/web/access/create/helpers/index.ts @@ -1,2 +1,3 @@ +export { matches } from "./quick-create-dialog-values"; export type { QuickCreatePlan } from "./quick-create-plan"; export { matchesSubmittedValues, quickCreatePlan } from "./quick-create-plan"; diff --git a/modules/companies/src/web/access/create/helpers/quick-create-dialog-values.ts b/modules/companies/src/web/access/create/helpers/quick-create-dialog-values.ts new file mode 100644 index 0000000..0916b70 --- /dev/null +++ b/modules/companies/src/web/access/create/helpers/quick-create-dialog-values.ts @@ -0,0 +1,11 @@ +import type { AccessFieldsDTO } from "../../../../common"; + +export function matches(item: AccessFieldsDTO, values: AccessFieldsDTO) { + if (values.kind === "system" && item.kind === "system") { + return item.name === values.name; + } + if (values.kind === "environment" && item.kind === "environment") { + return item.name === values.name && item.systemId === values.systemId; + } + return false; +} diff --git a/modules/companies/src/web/access/create/helpers/quick-create-plan.ts b/modules/companies/src/web/access/create/helpers/quick-create-plan.ts index 6ae7f15..be78ed7 100644 --- a/modules/companies/src/web/access/create/helpers/quick-create-plan.ts +++ b/modules/companies/src/web/access/create/helpers/quick-create-plan.ts @@ -1,6 +1,6 @@ import type { AccessFieldsDTO, AccessKind, AccessResponseDTO } from "../../../../common"; import type { AccessField, AccessFormValues } from "../../shared/helpers"; -import type { QuickCreateTarget } from "../ui/quick-create-dialog"; +import type { QuickCreateTarget } from "../types"; export type QuickCreatePlan = | { type: "simple"; target: QuickCreateTarget } diff --git a/modules/companies/src/web/access/create/types/access-quick-editor-props.ts b/modules/companies/src/web/access/create/types/access-quick-editor-props.ts new file mode 100644 index 0000000..a24a9cf --- /dev/null +++ b/modules/companies/src/web/access/create/types/access-quick-editor-props.ts @@ -0,0 +1,16 @@ +import type { ComponentType } from "react"; + +import type { AccessFieldsDTO, AccessResponseDTO, PersonDTO } from "../../../../common"; +import type { AccessFormProps } from "../../shared/types"; +import type { QuickCreatePlan } from "../helpers"; + +export type AccessQuickEditorProps = { + plan: QuickCreatePlan; + scope: string; + data: AccessResponseDTO; + people: readonly PersonDTO[]; + csrfToken: string; + onCreated: (id?: string, values?: AccessFieldsDTO) => void; + onClose: () => void; + nestedAccessForm: ComponentType; +}; diff --git a/modules/companies/src/web/access/create/types/index.ts b/modules/companies/src/web/access/create/types/index.ts new file mode 100644 index 0000000..e97e745 --- /dev/null +++ b/modules/companies/src/web/access/create/types/index.ts @@ -0,0 +1,2 @@ +export type { AccessQuickEditorProps } from "./access-quick-editor-props"; +export type { QuickCreateDialogProps, QuickCreateTarget } from "./quick-create-dialog-props"; diff --git a/modules/companies/src/web/access/create/types/quick-create-dialog-props.ts b/modules/companies/src/web/access/create/types/quick-create-dialog-props.ts new file mode 100644 index 0000000..73f56d8 --- /dev/null +++ b/modules/companies/src/web/access/create/types/quick-create-dialog-props.ts @@ -0,0 +1,20 @@ +import type { AccessItemDTO } from "../../../../common"; + +export type QuickCreateTarget = + | { + kind: "system"; + } + | { + kind: "environment"; + systemId: string; + systemName: string; + }; + +export type QuickCreateDialogProps = { + target: QuickCreateTarget; + scope: string; + csrfToken: string; + onClose: () => void; + onCreated: (id: string) => void; + items: readonly AccessItemDTO[]; +}; diff --git a/modules/companies/src/web/access/create/ui/access-quick-editor.tsx b/modules/companies/src/web/access/create/ui/access-quick-editor.tsx index befd4b4..df4a4b7 100644 --- a/modules/companies/src/web/access/create/ui/access-quick-editor.tsx +++ b/modules/companies/src/web/access/create/ui/access-quick-editor.tsx @@ -1,59 +1,17 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { useQuery } from "@tanstack/react-query"; -import type { ComponentType } from "react"; -import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import type { AccessFieldsDTO, AccessResponseDTO, PersonDTO } from "../../../../common"; import { PersonForm } from "../../../people"; -import { accessApi, accessKeys } from "../../shared/api"; import { createLabels } from "../../shared/helpers"; -import type { AccessFormProps } from "../../shared/types"; -import type { QuickCreatePlan } from "../helpers"; +import { useAccessQuickEditorController } from "../controllers"; +import type { AccessQuickEditorProps } from "../types"; import { QuickCreateDialog } from "./quick-create-dialog"; -export function AccessQuickEditor({ - plan, - scope, - data, - people, - csrfToken, - onCreated, - onClose, - nestedAccessForm: NestedAccessForm, -}: { - plan: QuickCreatePlan; - scope: string; - data: AccessResponseDTO; - people: readonly PersonDTO[]; - csrfToken: string; - onCreated: (id?: string, values?: AccessFieldsDTO) => void; - onClose: () => void; - nestedAccessForm: ComponentType; -}) { +export function AccessQuickEditor(props: AccessQuickEditorProps) { + const { plan, scope, data, people, csrfToken, nestedAccessForm: NestedAccessForm } = props; + const { close, created, globalQuery } = useAccessQuickEditorController(props); const { t } = useTranslation("access"); - const active = useRef(true); - useEffect(() => { - active.current = true; - return () => { - active.current = false; - }; - }, []); - const close = () => { - active.current = false; - onClose(); - }; - const created = (id?: string, values?: AccessFieldsDTO) => { - if (active.current) { - onCreated(id, values); - } - }; - const globalQuery = useQuery({ - queryKey: accessKeys.scope("global"), - queryFn: () => accessApi.request("global"), - enabled: plan.type === "access" && plan.scope === "global" && scope !== "global", - }); if (plan.type === "simple") { return ( void; - onCreated: (id: string) => void; - items: readonly AccessItemDTO[]; -}) { +export function QuickCreateDialog(props: QuickCreateDialogProps) { + const { target, scope, onCreated } = props; + const { form, existing, mutation, submit, changeOpen } = useQuickCreateDialogController(props); const { t } = useTranslation("access"); const id = useId(); - const client = useQueryClient(); - const dismissed = useRef(false); - const form = useForm<{ name: string }>({ defaultValues: { name: "" } }); - const enteredName = form.watch("name").trim().toLocaleLowerCase(); - const existing = items.find((item) => { - const values = item.values; - return ( - "name" in values && - "active" in values && - values.active && - values.name.toLocaleLowerCase() === enteredName && - (target.kind === "system" - ? values.kind === "system" - : values.kind === "environment" && values.systemId === target.systemId) - ); - }); - const mutation = useMutation({ - mutationFn: async ({ name }: { name: string }) => { - const values = AccessFieldsSchema.parse( - target.kind === "system" - ? { kind: "system", name, active: true } - : { kind: "environment", name, active: true, systemId: target.systemId } - ); - const result = await accessApi.request(scope, values, undefined, csrfToken); - const created = result.items.find( - (item) => - item.companyId === (scope === "global" ? null : scope) && matches(item.values, values) - ); - if (!created) { - throw new AccessApiError("INTERNAL_ERROR"); - } - return { result, id: created.id }; - }, - onSuccess: async ({ result, id }) => { - await client.invalidateQueries({ queryKey: ["access"] }); - client.setQueryData(accessKeys.scope(scope), result); - if (!dismissed.current) { - onCreated(id); - } - }, - }); return ( { - if (!open) { - dismissed.current = true; - onClose(); - } - }} + onOpenChange={changeOpen} open title={t(target.kind === "system" ? "quickAddSystem" : "quickAddEnvironment")} > - mutation.mutate(values))}> + {target.kind === "environment" && (

{t("quickEnvironmentContext", { name: target.systemName })} @@ -138,13 +74,3 @@ export function QuickCreateDialog({ ); } - -function matches(item: AccessFieldsDTO, values: AccessFieldsDTO) { - if (values.kind === "system" && item.kind === "system") { - return item.name === values.name; - } - if (values.kind === "environment" && item.kind === "environment") { - return item.name === values.name && item.systemId === values.systemId; - } - return false; -} diff --git a/modules/companies/src/web/access/history/ui/access-history.tsx b/modules/companies/src/web/access/history/ui/access-history.tsx index bfec51f..52b3c4f 100644 --- a/modules/companies/src/web/access/history/ui/access-history.tsx +++ b/modules/companies/src/web/access/history/ui/access-history.tsx @@ -2,7 +2,7 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import { useTranslation } from "react-i18next"; import type { AccessItemDTO, PersonDTO } from "../../../../common"; -import { accessLabel } from "../../shared"; +import { accessLabel } from "../../shared/helpers"; function historyValue( key: string, diff --git a/modules/companies/src/web/access/index.ts b/modules/companies/src/web/access/index.ts index b495dea..5e41faa 100644 --- a/modules/companies/src/web/access/index.ts +++ b/modules/companies/src/web/access/index.ts @@ -1,10 +1,5 @@ export { EntityHelp } from "./help"; export { AccessWorkspacePage } from "./list"; -export { - AccessForm, - accessApi, - accessEnglish, - accessKeys, - accessLabel, - accessSpanish, -} from "./shared"; +export { AccessForm, accessEnglish, accessSpanish } from "./shared"; +export { accessLabel } from "./shared/helpers"; +export { accessKeys } from "./shared/queries"; diff --git a/modules/companies/src/web/access/list/api/get-access.api.ts b/modules/companies/src/web/access/list/api/get-access.api.ts new file mode 100644 index 0000000..551f5e2 --- /dev/null +++ b/modules/companies/src/web/access/list/api/get-access.api.ts @@ -0,0 +1,5 @@ +import { accessRequest } from "../../shared/api/access-request"; + +export function getAccess(scope: string) { + return accessRequest(scope); +} diff --git a/modules/companies/src/web/access/list/api/index.ts b/modules/companies/src/web/access/list/api/index.ts new file mode 100644 index 0000000..3659fee --- /dev/null +++ b/modules/companies/src/web/access/list/api/index.ts @@ -0,0 +1 @@ +export { getAccess } from "./get-access.api"; diff --git a/modules/companies/src/web/access/list/controllers/index.ts b/modules/companies/src/web/access/list/controllers/index.ts new file mode 100644 index 0000000..2dfbbc8 --- /dev/null +++ b/modules/companies/src/web/access/list/controllers/index.ts @@ -0,0 +1 @@ +export { useAccessWorkspacePageController } from "./use-access-workspace-controller"; diff --git a/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts b/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts new file mode 100644 index 0000000..aa9f6f7 --- /dev/null +++ b/modules/companies/src/web/access/list/controllers/use-access-workspace-controller.ts @@ -0,0 +1,66 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { AccessKind } from "../../../../common"; +import { accessFields, accessLabel } from "../../shared/helpers"; +import { accessKeys } from "../../shared/queries"; +import { getAccess } from "../api"; +import type { AccessWorkspacePageProps } from "../types"; + +export function useAccessWorkspacePageController({ scope, people = [] }: AccessWorkspacePageProps) { + const query = useQuery({ + queryKey: accessKeys.scope(scope), + queryFn: () => getAccess(scope), + }); + const [kind, setKind] = useState(scope === "global" ? "system" : "account"); + const [editor, setEditor] = useState(); + const [history, setHistory] = useState(); + const [helpOpen, setHelpOpen] = useState(false); + const [search, setSearch] = useState(""); + const data = query.data; + const selected = data?.items.find((item) => item.id === editor); + const historical = data?.items.find((item) => item.id === history); + const kinds: readonly AccessKind[] = + scope === "global" + ? ["system", "resource_type", "permission", "template"] + : [ + "account", + "account_person", + "group", + "membership", + "grant", + "need", + "coverage", + "environment", + "system", + "resource_type", + "permission", + ]; + const visible = + data?.items.filter( + (item) => + item.values.kind === kind && + accessLabel(item, data.items, people) + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()) + ) ?? []; + const complex = accessFields[kind].length > 5; + return { + query, + kind, + setKind, + editor, + setEditor, + setHistory, + helpOpen, + setHelpOpen, + search, + setSearch, + data, + selected, + historical, + kinds, + visible, + complex, + }; +} diff --git a/modules/companies/src/web/access/list/types/access-workspace-props.ts b/modules/companies/src/web/access/list/types/access-workspace-props.ts new file mode 100644 index 0000000..d406eac --- /dev/null +++ b/modules/companies/src/web/access/list/types/access-workspace-props.ts @@ -0,0 +1,7 @@ +import type { PersonDTO } from "../../../../common"; + +export type AccessWorkspacePageProps = { + scope: string; + people?: readonly PersonDTO[]; + csrfToken: string; +}; diff --git a/modules/companies/src/web/access/list/types/index.ts b/modules/companies/src/web/access/list/types/index.ts new file mode 100644 index 0000000..087ec1f --- /dev/null +++ b/modules/companies/src/web/access/list/types/index.ts @@ -0,0 +1 @@ +export type { AccessWorkspacePageProps } from "./access-workspace-props"; diff --git a/modules/companies/src/web/access/list/ui/access-row.tsx b/modules/companies/src/web/access/list/ui/access-row.tsx index 8148e44..a695a34 100644 --- a/modules/companies/src/web/access/list/ui/access-row.tsx +++ b/modules/companies/src/web/access/list/ui/access-row.tsx @@ -2,7 +2,7 @@ import { EntityAction, EntityRowActions } from "@repo/shadcn-ui/components/entit import { useTranslation } from "react-i18next"; import type { AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../../../common"; -import { accessLabel } from "../../shared"; +import { accessLabel } from "../../shared/helpers"; import { EntryState } from "./entry-state"; diff --git a/modules/companies/src/web/access/list/ui/access-workspace.tsx b/modules/companies/src/web/access/list/ui/access-workspace.tsx index 8d49617..e5515aa 100644 --- a/modules/companies/src/web/access/list/ui/access-workspace.tsx +++ b/modules/companies/src/web/access/list/ui/access-workspace.tsx @@ -1,71 +1,41 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { AccessKind, PersonDTO } from "../../../../common"; import { AccessHelpDialog } from "../../help"; import { AccessHistory } from "../../history"; -import { - AccessApiError, - AccessEditorDialog, - accessApi, - accessFields, - accessKeys, - accessLabel, - createLabels, -} from "../../shared"; +import { AccessEditorDialog } from "../../shared"; +import { AccessApiError } from "../../shared/api"; +import { accessLabel, createLabels } from "../../shared/helpers"; +import { useAccessWorkspacePageController } from "../controllers"; +import type { AccessWorkspacePageProps } from "../types"; import { AccessCell } from "./access-row"; -export function AccessWorkspacePage({ - scope, - people = [], - csrfToken, -}: { - scope: string; - people?: readonly PersonDTO[]; - csrfToken: string; -}) { +function errorCode(error: Error) { + return error instanceof AccessApiError ? error.code : "INTERNAL_ERROR"; +} +export function AccessWorkspacePage(props: AccessWorkspacePageProps) { + const { scope, people = [], csrfToken } = props; + const { + query, + kind, + setKind, + editor, + setEditor, + setHistory, + helpOpen, + setHelpOpen, + search, + setSearch, + data, + selected, + historical, + kinds, + visible, + complex, + } = useAccessWorkspacePageController(props); const { t, i18n } = useTranslation("access"); - const query = useQuery({ - queryKey: accessKeys.scope(scope), - queryFn: () => accessApi.request(scope), - }); - const [kind, setKind] = useState(scope === "global" ? "system" : "account"); - const [editor, setEditor] = useState(); - const [history, setHistory] = useState(); - const [helpOpen, setHelpOpen] = useState(false); - const [search, setSearch] = useState(""); - const data = query.data; - const selected = data?.items.find((item) => item.id === editor); - const historical = data?.items.find((item) => item.id === history); - const kinds: readonly AccessKind[] = - scope === "global" - ? ["system", "resource_type", "permission", "template"] - : [ - "account", - "account_person", - "group", - "membership", - "grant", - "need", - "coverage", - "environment", - "system", - "resource_type", - "permission", - ]; - const visible = - data?.items.filter( - (item) => - item.values.kind === kind && - accessLabel(item, data.items, people) - .toLocaleLowerCase() - .includes(search.trim().toLocaleLowerCase()) - ) ?? []; - const complex = accessFields[kind].length > 5; return (

); } - -function errorCode(error: Error) { - return error instanceof AccessApiError ? error.code : "INTERNAL_ERROR"; -} diff --git a/modules/companies/src/web/access/shared/access.test.tsx b/modules/companies/src/web/access/shared/access.test.tsx index 2bc64e2..bfb77e4 100644 --- a/modules/companies/src/web/access/shared/access.test.tsx +++ b/modules/companies/src/web/access/shared/access.test.tsx @@ -7,12 +7,12 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nextProvider } from "react-i18next"; import type { AccessResponseDTO } from "../../../common"; -import { quickCreatePlan } from "../create"; +import { quickCreatePlan } from "../create/helpers"; import { AccessWorkspacePage } from "../list"; -import { accessKeys } from "./api"; import { accessFields } from "./helpers"; import { accessEnglish, accessSpanish } from "./i18n"; +import { accessKeys } from "./queries"; import { AccessForm } from "./ui"; test("los catálogos presentan alta mediante acción y no un formulario junto al listado", async () => { diff --git a/modules/companies/src/web/access/shared/api/access-api-error.ts b/modules/companies/src/web/access/shared/api/access-api-error.ts new file mode 100644 index 0000000..e030666 --- /dev/null +++ b/modules/companies/src/web/access/shared/api/access-api-error.ts @@ -0,0 +1,5 @@ +export class AccessApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/web/access/shared/api/access-api.ts b/modules/companies/src/web/access/shared/api/access-api.ts deleted file mode 100644 index 202982a..0000000 --- a/modules/companies/src/web/access/shared/api/access-api.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod/v4"; - -import type { AccessFieldsDTO } from "../../../../common"; -import { AccessResponseSchema } from "../../../../common"; -export const accessKeys = { scope: (scope: string) => ["access", scope] }; -export class AccessApiError extends Error { - constructor(public readonly code: string) { - super(code); - } -} -export const accessApi = { - async request(scope: string, values?: AccessFieldsDTO, id?: string, token?: string) { - const response = await fetch(`/api/access/${scope}${id ? `/${id}/update` : ""}`, { - method: values ? "POST" : "GET", - credentials: "same-origin", - headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, - ...(values ? { body: JSON.stringify({ values }) } : {}), - }); - const json: unknown = await response.json(); - if (!response.ok) { - const parsed = z.object({ code: z.string() }).safeParse(json); - throw new AccessApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR"); - } - return AccessResponseSchema.parse(json); - }, -}; diff --git a/modules/companies/src/web/access/shared/api/access-request.ts b/modules/companies/src/web/access/shared/api/access-request.ts new file mode 100644 index 0000000..4f633a9 --- /dev/null +++ b/modules/companies/src/web/access/shared/api/access-request.ts @@ -0,0 +1,26 @@ +import { z } from "zod/v4"; + +import type { AccessFieldsDTO } from "../../../../common"; +import { AccessResponseSchema } from "../../../../common"; + +import { AccessApiError } from "./access-api-error"; + +export async function accessRequest( + scope: string, + values?: AccessFieldsDTO, + id?: string, + token?: string +) { + const response = await fetch(`/api/access/${scope}${id ? `/${id}/update` : ""}`, { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, + ...(values ? { body: JSON.stringify({ values }) } : {}), + }); + const json: unknown = await response.json(); + if (!response.ok) { + const parsed = z.object({ code: z.string() }).safeParse(json); + throw new AccessApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR"); + } + return AccessResponseSchema.parse(json); +} diff --git a/modules/companies/src/web/access/shared/api/index.ts b/modules/companies/src/web/access/shared/api/index.ts index c308e59..d72f05f 100644 --- a/modules/companies/src/web/access/shared/api/index.ts +++ b/modules/companies/src/web/access/shared/api/index.ts @@ -1 +1,2 @@ -export { AccessApiError, accessApi, accessKeys } from "./access-api"; +export { AccessApiError } from "./access-api-error"; +export { saveAccess } from "./save-access.api"; diff --git a/modules/companies/src/web/access/shared/api/save-access.api.ts b/modules/companies/src/web/access/shared/api/save-access.api.ts new file mode 100644 index 0000000..7d181dd --- /dev/null +++ b/modules/companies/src/web/access/shared/api/save-access.api.ts @@ -0,0 +1,12 @@ +import type { AccessFieldsDTO } from "../../../../common"; + +import { accessRequest } from "./access-request"; + +export function saveAccess( + scope: string, + values: AccessFieldsDTO, + id: string | undefined, + token: string +) { + return accessRequest(scope, values, id, token); +} diff --git a/modules/companies/src/web/access/shared/controllers/index.ts b/modules/companies/src/web/access/shared/controllers/index.ts new file mode 100644 index 0000000..9bb2cec --- /dev/null +++ b/modules/companies/src/web/access/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useAccessFormFieldsController } from "./use-access-form-fields-controller"; diff --git a/modules/companies/src/web/access/shared/controllers/use-access-form-fields-controller.ts b/modules/companies/src/web/access/shared/controllers/use-access-form-fields-controller.ts new file mode 100644 index 0000000..d1d5623 --- /dev/null +++ b/modules/companies/src/web/access/shared/controllers/use-access-form-fields-controller.ts @@ -0,0 +1,160 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ComponentType } from "react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { AccessFieldsDTO } from "../../../../common"; +import { AccessFieldsSchema } from "../../../../common"; +import type { QuickCreatePlan } from "../../create/helpers"; +import { matchesSubmittedValues, quickCreatePlan } from "../../create/helpers"; +import { AccessApiError, saveAccess } from "../api"; +import type { AccessField, AccessFormValues } from "../helpers"; +import { accessFields, accessLabel, compatibleOption, fieldDefault, formBody } from "../helpers"; +import { accessKeys } from "../queries"; +import type { AccessFormProps } from "../types"; + +export function useAccessFormFieldsController({ + scope, + kind, + item, + data, + people, + csrfToken, + onSaved, + allowQuickCreate = true, + initialValues = {}, + lockedFields = [], +}: AccessFormProps & { + nestedAccessForm: ComponentType; +}) { + const { t } = useTranslation("access"); + const client = useQueryClient(); + const [validation, setValidation] = useState(); + const fields = accessFields[kind]; + const [quickCreate, setQuickCreate] = useState<{ + field: AccessField; + plan: QuickCreatePlan; + }>(); + const form = useForm({ + defaultValues: Object.fromEntries( + fields.map((field) => [field.key, initialValues[field.key] ?? fieldDefault(field, item)]) + ), + }); + const watched = form.watch(); + const text = (key: string) => (typeof watched[key] === "string" ? watched[key] : ""); + const account = data.items.find((target) => target.id === text("accountId"))?.values; + const technical = + kind === "account_person" && account?.kind === "account" && account.usage === "technical"; + const mutation = useMutation({ + mutationFn: (values: unknown) => { + const parsed = AccessFieldsSchema.safeParse(values); + if (!parsed.success) { + throw new AccessApiError("INVALID_REQUEST"); + } + return saveAccess(scope, parsed.data, item?.id, csrfToken); + }, + onSuccess: async (result, submitted) => { + const savedId = + item?.id ?? + result.items.find( + (candidate) => + candidate.companyId === (scope === "global" ? null : scope) && + !data.items.some((existing) => existing.id === candidate.id) && + matchesSubmittedValues(candidate.values, AccessFieldsSchema.parse(submitted)) + )?.id; + await client.invalidateQueries({ queryKey: ["access"] }); + client.setQueryData(accessKeys.scope(scope), result); + onSaved(savedId, AccessFieldsSchema.parse(submitted)); + }, + }); + const submit = form.handleSubmit((raw) => { + setValidation(undefined); + const parsed = AccessFieldsSchema.safeParse(formBody(kind, fields, raw, technical)); + if (!parsed.success) { + setValidation(t("invalid")); + return; + } + mutation.mutate(parsed.data); + }); + const select = (field: AccessField, value: string, createdValues?: AccessFieldsDTO) => { + form.setValue(field.key, value); + if (field.key === "resourceId" || field.key === "resourceTypeId") { + form.setValue("permissionId", ""); + form.setValue("permissionIds", []); + } + if (field.key === "systemId") { + form.setValue("environmentId", ""); + } + if (field.key === "accountId" && ["membership", "grant"].includes(kind)) { + form.setValue("groupId", ""); + } + if (field.key === "groupId" && kind === "grant") { + form.setValue("accountId", ""); + } + if (field.key === "templateId") { + applyTemplate(value, createdValues); + } + }; + const applyTemplate = (id: string, createdValues?: AccessFieldsDTO) => { + const template = createdValues ?? data.items.find((target) => target.id === id)?.values; + if (template?.kind !== "template") { + return; + } + form.setValue("name", template.name); + form.setValue("description", template.description ?? ""); + if (template.systemId && !lockedFields.includes("systemId")) { + form.setValue("systemId", template.systemId); + form.setValue("environmentId", ""); + } + }; + const options = (field: AccessField) => { + if (field.options) { + return field.options.map((value) => ({ value, label: t(value) })); + } + if (field.key === "personId") { + return people + .filter((person) => person.active || person.id === text(field.key)) + .map((person) => ({ value: person.id, label: person.name })); + } + return data.items + .filter((target) => + compatibleOption(target, field, text(field.key), watched, data.items, kind) + ) + .map((target) => ({ value: target.id, label: accessLabel(target, data.items, people) })); + }; + const quickAction = (field: AccessField) => { + if (!allowQuickCreate || (item && field.fixed) || lockedFields.includes(field.key)) { + return undefined; + } + const plan = quickCreatePlan(field, scope, kind, data, watched); + return plan ? () => setQuickCreate({ field, plan }) : undefined; + }; + const created = (id?: string, values?: AccessFieldsDTO) => { + if (quickCreate && id) { + select(quickCreate.field, id, values); + setQuickCreate(undefined); + } + }; + const help: Readonly> = { + account: "accountHelp", + coverage: "coverageHelp", + template: "templateHelp", + }; + const editableFields = fields.filter((field) => field.key !== "configuration" || !technical); + return { + validation, + quickCreate, + setQuickCreate, + form, + watched, + mutation, + submit, + select, + options, + quickAction, + created, + help, + editableFields, + }; +} diff --git a/modules/companies/src/web/access/shared/index.ts b/modules/companies/src/web/access/shared/index.ts index 2d31d48..2968864 100644 --- a/modules/companies/src/web/access/shared/index.ts +++ b/modules/companies/src/web/access/shared/index.ts @@ -1,4 +1,5 @@ -export { AccessApiError, accessApi, accessKeys } from "./api"; +export { AccessApiError } from "./api"; export { accessFields, accessLabel, createLabels } from "./helpers"; export { accessEnglish, accessSpanish } from "./i18n"; +export { accessKeys } from "./queries"; export { AccessEditorDialog, AccessForm } from "./ui"; diff --git a/modules/companies/src/web/access/shared/queries/access-query-keys.ts b/modules/companies/src/web/access/shared/queries/access-query-keys.ts new file mode 100644 index 0000000..b4476fe --- /dev/null +++ b/modules/companies/src/web/access/shared/queries/access-query-keys.ts @@ -0,0 +1 @@ +export const accessKeys = { scope: (scope: string) => ["access", scope] }; diff --git a/modules/companies/src/web/access/shared/queries/index.ts b/modules/companies/src/web/access/shared/queries/index.ts new file mode 100644 index 0000000..aa2c637 --- /dev/null +++ b/modules/companies/src/web/access/shared/queries/index.ts @@ -0,0 +1 @@ +export { accessKeys } from "./access-query-keys"; diff --git a/modules/companies/src/web/access/shared/ui/access-form-fields.tsx b/modules/companies/src/web/access/shared/ui/access-form-fields.tsx index 3efa82d..7ddf924 100644 --- a/modules/companies/src/web/access/shared/ui/access-form-fields.tsx +++ b/modules/companies/src/web/access/shared/ui/access-form-fields.tsx @@ -1,153 +1,40 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { ComponentType } from "react"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { AccessFieldsDTO } from "../../../../common"; -import { AccessFieldsSchema } from "../../../../common"; -import type { QuickCreatePlan } from "../../create"; -import { AccessQuickEditor, matchesSubmittedValues, quickCreatePlan } from "../../create"; +import { AccessQuickEditor } from "../../create"; import { EntityHelp } from "../../help"; -import { AccessApiError, accessApi, accessKeys } from "../api"; -import type { AccessField, AccessFormValues } from "../helpers"; -import { accessFields, accessLabel, compatibleOption, fieldDefault, formBody } from "../helpers"; +import { AccessApiError } from "../api"; +import { useAccessFormFieldsController } from "../controllers"; import type { AccessFormProps } from "../types"; import { AccessFieldControl } from "./access-field-control"; -export function AccessFormFields({ - scope, - kind, - item, - data, - people, - csrfToken, - onSaved, - allowQuickCreate = true, - initialValues = {}, - lockedFields = [], - nestedAccessForm, -}: AccessFormProps & { - nestedAccessForm: ComponentType; -}) { +export function AccessFormFields( + props: AccessFormProps & { + nestedAccessForm: ComponentType; + } +) { + const { scope, kind, item, data, people, csrfToken, lockedFields = [], nestedAccessForm } = props; + const { + validation, + quickCreate, + setQuickCreate, + form, + watched, + mutation, + submit, + select, + options, + quickAction, + created, + help, + editableFields, + } = useAccessFormFieldsController(props); const { t } = useTranslation("access"); const prefix = useId(); - const client = useQueryClient(); - const [validation, setValidation] = useState(); - const fields = accessFields[kind]; - const [quickCreate, setQuickCreate] = useState<{ - field: AccessField; - plan: QuickCreatePlan; - }>(); - const form = useForm({ - defaultValues: Object.fromEntries( - fields.map((field) => [field.key, initialValues[field.key] ?? fieldDefault(field, item)]) - ), - }); - const watched = form.watch(); - const text = (key: string) => (typeof watched[key] === "string" ? watched[key] : ""); - const account = data.items.find((target) => target.id === text("accountId"))?.values; - const technical = - kind === "account_person" && account?.kind === "account" && account.usage === "technical"; - const mutation = useMutation({ - mutationFn: (values: unknown) => { - const parsed = AccessFieldsSchema.safeParse(values); - if (!parsed.success) { - throw new AccessApiError("INVALID_REQUEST"); - } - return accessApi.request(scope, parsed.data, item?.id, csrfToken); - }, - onSuccess: async (result, submitted) => { - const savedId = - item?.id ?? - result.items.find( - (candidate) => - candidate.companyId === (scope === "global" ? null : scope) && - !data.items.some((existing) => existing.id === candidate.id) && - matchesSubmittedValues(candidate.values, AccessFieldsSchema.parse(submitted)) - )?.id; - await client.invalidateQueries({ queryKey: ["access"] }); - client.setQueryData(accessKeys.scope(scope), result); - onSaved(savedId, AccessFieldsSchema.parse(submitted)); - }, - }); - const submit = form.handleSubmit((raw) => { - setValidation(undefined); - const parsed = AccessFieldsSchema.safeParse(formBody(kind, fields, raw, technical)); - if (!parsed.success) { - setValidation(t("invalid")); - return; - } - mutation.mutate(parsed.data); - }); - const select = (field: AccessField, value: string, createdValues?: AccessFieldsDTO) => { - form.setValue(field.key, value); - if (field.key === "resourceId" || field.key === "resourceTypeId") { - form.setValue("permissionId", ""); - form.setValue("permissionIds", []); - } - if (field.key === "systemId") { - form.setValue("environmentId", ""); - } - if (field.key === "accountId" && ["membership", "grant"].includes(kind)) { - form.setValue("groupId", ""); - } - if (field.key === "groupId" && kind === "grant") { - form.setValue("accountId", ""); - } - if (field.key === "templateId") { - applyTemplate(value, createdValues); - } - }; - const applyTemplate = (id: string, createdValues?: AccessFieldsDTO) => { - const template = createdValues ?? data.items.find((target) => target.id === id)?.values; - if (template?.kind !== "template") { - return; - } - form.setValue("name", template.name); - form.setValue("description", template.description ?? ""); - if (template.systemId && !lockedFields.includes("systemId")) { - form.setValue("systemId", template.systemId); - form.setValue("environmentId", ""); - } - }; - const options = (field: AccessField) => { - if (field.options) { - return field.options.map((value) => ({ value, label: t(value) })); - } - if (field.key === "personId") { - return people - .filter((person) => person.active || person.id === text(field.key)) - .map((person) => ({ value: person.id, label: person.name })); - } - return data.items - .filter((target) => - compatibleOption(target, field, text(field.key), watched, data.items, kind) - ) - .map((target) => ({ value: target.id, label: accessLabel(target, data.items, people) })); - }; - const quickAction = (field: AccessField) => { - if (!allowQuickCreate || (item && field.fixed) || lockedFields.includes(field.key)) { - return undefined; - } - const plan = quickCreatePlan(field, scope, kind, data, watched); - return plan ? () => setQuickCreate({ field, plan }) : undefined; - }; - const created = (id?: string, values?: AccessFieldsDTO) => { - if (quickCreate && id) { - select(quickCreate.field, id, values); - setQuickCreate(undefined); - } - }; - const help: Readonly> = { - account: "accountHelp", - coverage: "coverageHelp", - template: "templateHelp", - }; - const editableFields = fields.filter((field) => field.key !== "configuration" || !technical); return ( <> diff --git a/modules/companies/src/web/application-configurations/index.ts b/modules/companies/src/web/application-configurations/index.ts index 7d312b6..e937c3c 100644 --- a/modules/companies/src/web/application-configurations/index.ts +++ b/modules/companies/src/web/application-configurations/index.ts @@ -1,2 +1,2 @@ export { ApplicationConfigurationsPage } from "./list"; -export { applicationApi, applicationKeys } from "./shared"; +export { applicationApi, applicationKeys } from "./shared/api"; diff --git a/modules/companies/src/web/application-configurations/list/controllers/index.ts b/modules/companies/src/web/application-configurations/list/controllers/index.ts new file mode 100644 index 0000000..17a7170 --- /dev/null +++ b/modules/companies/src/web/application-configurations/list/controllers/index.ts @@ -0,0 +1 @@ +export { useApplicationConfigurationsPageController } from "./use-application-configurations-page-controller"; diff --git a/modules/companies/src/web/application-configurations/list/controllers/use-application-configurations-page-controller.ts b/modules/companies/src/web/application-configurations/list/controllers/use-application-configurations-page-controller.ts new file mode 100644 index 0000000..4837a30 --- /dev/null +++ b/modules/companies/src/web/application-configurations/list/controllers/use-application-configurations-page-controller.ts @@ -0,0 +1,77 @@ +import { inventoryApi, inventoryKeys } from "@metis/assets/client"; +import { + inventoryApi as resourceApi, + inventoryKeys as resourceKeys, +} from "@metis/resources/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; +import { applicationApi, applicationKeys } from "../../shared/api"; +import type { ApplicationConfigurationsPageProps } from "../types"; + +export function useApplicationConfigurationsPageController({ + company, + initialProfileId, +}: ApplicationConfigurationsPageProps) { + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: applicationKeys.company(company.id), + queryFn: () => applicationApi.get(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const resources = useQuery({ + queryKey: resourceKeys.company(company.id), + queryFn: () => resourceApi.get(company.id), + }); + const resource = (id: string) => + resources.data?.resources.find((item) => item.id === id)?.values.name ?? id; + const configurations = query.data?.configurations ?? []; + const asset = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id); + const label = (id: string) => { + const item = profile(id); + return item + ? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}` + : id; + }; + const filtered = configurations.filter( + (item) => + (!initialProfileId || item.values.profileId === initialProfileId) && + `${resource(item.values.resourceId)} ${label(item.values.profileId)}` + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()) + ); + const edited = configurations.find((item) => item.id === editor); + const viewed = configurations.find((item) => item.id === view); + const ready = [query.data, profiles.data, inventory.data, resources.data].every(Boolean); + const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; + return { + editor, + setEditor, + setView, + search, + setSearch, + query, + profiles, + inventory, + resources, + resource, + label, + filtered, + edited, + viewed, + ready, + canCreate, + }; +} diff --git a/modules/companies/src/web/application-configurations/list/types/application-configurations-page-props.ts b/modules/companies/src/web/application-configurations/list/types/application-configurations-page-props.ts new file mode 100644 index 0000000..fd3896b --- /dev/null +++ b/modules/companies/src/web/application-configurations/list/types/application-configurations-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type ApplicationConfigurationsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/application-configurations/list/types/index.ts b/modules/companies/src/web/application-configurations/list/types/index.ts new file mode 100644 index 0000000..814efaa --- /dev/null +++ b/modules/companies/src/web/application-configurations/list/types/index.ts @@ -0,0 +1 @@ +export type { ApplicationConfigurationsPageProps } from "./application-configurations-page-props"; diff --git a/modules/companies/src/web/application-configurations/list/ui/application-configurations-page.tsx b/modules/companies/src/web/application-configurations/list/ui/application-configurations-page.tsx index 6acaf2a..f1b1e68 100644 --- a/modules/companies/src/web/application-configurations/list/ui/application-configurations-page.tsx +++ b/modules/companies/src/web/application-configurations/list/ui/application-configurations-page.tsx @@ -1,8 +1,3 @@ -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; -import { - inventoryApi as resourceApi, - inventoryKeys as resourceKeys, -} from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -11,72 +6,34 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { ApplicationHistoryDialog } from "../../history"; -import { - ApplicationConfigurationEditorDialog, - ApplicationFeedback, - applicationApi, - applicationKeys, -} from "../../shared"; +import { ApplicationConfigurationEditorDialog, ApplicationFeedback } from "../../shared"; +import { useApplicationConfigurationsPageController } from "../controllers"; +import type { ApplicationConfigurationsPageProps } from "../types"; -export function ApplicationConfigurationsPage({ - company, - csrfToken, - initialProfileId, -}: { - company: CompanyDTO; - csrfToken: string; - initialProfileId?: string; -}) { +export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPageProps) { + const { company, csrfToken, initialProfileId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + query, + profiles, + inventory, + resources, + resource, + label, + filtered, + edited, + viewed, + ready, + canCreate, + } = useApplicationConfigurationsPageController(props); const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: applicationKeys.company(company.id), - queryFn: () => applicationApi.get(company.id), - }); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const resources = useQuery({ - queryKey: resourceKeys.company(company.id), - queryFn: () => resourceApi.get(company.id), - }); - const resource = (id: string) => - resources.data?.resources.find((item) => item.id === id)?.values.name ?? id; - const configurations = query.data?.configurations ?? []; - const asset = (id: string) => - inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; - const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id); - const label = (id: string) => { - const item = profile(id); - return item - ? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}` - : id; - }; - const filtered = configurations.filter( - (item) => - (!initialProfileId || item.values.profileId === initialProfileId) && - `${resource(item.values.resourceId)} ${label(item.values.profileId)}` - .toLocaleLowerCase() - .includes(search.trim().toLocaleLowerCase()) - ); - const edited = configurations.find((item) => item.id === editor); - const viewed = configurations.find((item) => item.id === view); - const ready = [query.data, profiles.data, inventory.data, resources.data].every(Boolean); - const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState<"profile" | "application">(); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const resources = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const catalog = useQuery({ + queryKey: catalogKeys.scope(company.id), + queryFn: () => catalogApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(ApplicationConfigurationInputSchema), + defaultValues: applicationDefaults(configuration, initialProfileId), + }); + const values = form.watch(); + const ready = [profiles.data, resources.data, catalog.data].every(Boolean); + const fixed = Boolean(configuration || initialProfileId); + const states = applicationStates(configuration); + const mutation = useMutation({ + mutationFn: (input: ApplicationConfigurationInput) => + applicationApi.save(company.id, input, csrfToken, configuration?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: applicationKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const changeDelivery = (value: string) => { + const parsed = ApplicationConfigurationInputSchema.shape.delivery.safeParse(value); + if (parsed.success) { + form.setValue("delivery", parsed.data); + if (parsed.data === "web") { + form.setValue("version", null); + } + } + }; + const changeState = (value: string) => { + const parsed = ApplicationConfigurationInputSchema.shape.state.safeParse(value); + if (parsed.success) { + form.setValue("state", parsed.data); + } + }; + const quickCreated = (field: "profileId" | "resourceId", id: string) => { + if (mounted.current) { + form.setValue(field, id); + setQuick(undefined); + } + }; + return { + quick, + setQuick, + profiles, + resources, + catalog, + form, + values, + ready, + fixed, + states, + mutation, + submit, + changeDelivery, + changeState, + quickCreated, + }; +} diff --git a/modules/companies/src/web/application-configurations/shared/helpers/application-configuration-form-values.ts b/modules/companies/src/web/application-configurations/shared/helpers/application-configuration-form-values.ts new file mode 100644 index 0000000..2e294e3 --- /dev/null +++ b/modules/companies/src/web/application-configurations/shared/helpers/application-configuration-form-values.ts @@ -0,0 +1,44 @@ +import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common"; + +import type { + ApplicationConfigurationDTO, + ApplicationConfigurationInput, +} from "../../../../common/application-configurations"; + +export function applicationOptions( + selected: string, + resources: readonly ResourceDTO[] = [], + items: readonly CatalogItemDTO[] = [] +) { + const templates = new Set( + items + .filter( + (item) => + item.values.kind === "template" && + (item.builtinKey === "template:application" || item.values.usage === "application") + ) + .map((item) => item.id) + ); + return resources + .filter( + (item) => + templates.has(item.values.templateId) && (item.values.active || item.id === selected) + ) + .map((item) => ({ value: item.id, label: item.values.name })); +} + +export function applicationDefaults( + configuration: ApplicationConfigurationDTO | undefined, + profileId: string +): ApplicationConfigurationInput { + return ( + configuration?.values ?? { + profileId, + resourceId: "", + delivery: "installed", + version: null, + state: "pending", + notes: null, + } + ); +} diff --git a/modules/companies/src/web/application-configurations/shared/helpers/index.ts b/modules/companies/src/web/application-configurations/shared/helpers/index.ts new file mode 100644 index 0000000..025ba7d --- /dev/null +++ b/modules/companies/src/web/application-configurations/shared/helpers/index.ts @@ -0,0 +1,4 @@ +export { + applicationDefaults, + applicationOptions, +} from "./application-configuration-form-values"; diff --git a/modules/companies/src/web/application-configurations/shared/types/application-configuration-form-props.ts b/modules/companies/src/web/application-configurations/shared/types/application-configuration-form-props.ts new file mode 100644 index 0000000..8362f4b --- /dev/null +++ b/modules/companies/src/web/application-configurations/shared/types/application-configuration-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { ApplicationConfigurationDTO } from "../../../../common/application-configurations"; + +export type ApplicationConfigurationFormProps = { + company: CompanyDTO; + csrfToken: string; + configuration?: ApplicationConfigurationDTO | undefined; + initialProfileId?: string; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/application-configurations/shared/types/index.ts b/modules/companies/src/web/application-configurations/shared/types/index.ts new file mode 100644 index 0000000..8fdc9a6 --- /dev/null +++ b/modules/companies/src/web/application-configurations/shared/types/index.ts @@ -0,0 +1 @@ +export type { ApplicationConfigurationFormProps } from "./application-configuration-form-props"; diff --git a/modules/companies/src/web/application-configurations/shared/ui/application-configuration-form.tsx b/modules/companies/src/web/application-configurations/shared/ui/application-configuration-form.tsx index 203e320..ee9cf86 100644 --- a/modules/companies/src/web/application-configurations/shared/ui/application-configuration-form.tsx +++ b/modules/companies/src/web/application-configurations/shared/ui/application-configuration-form.tsx @@ -1,6 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client"; -import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -8,84 +5,47 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import type { UseFormReturn } from "react-hook-form"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO, WorkProfileDTO } from "../../../../common"; -import type { - ApplicationConfigurationDTO, - ApplicationConfigurationInput, -} from "../../../../common/application-configurations"; -import { - APPLICATION_STATE_TRANSITIONS, - ApplicationConfigurationInputSchema, -} from "../../../../common/application-configurations"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; +import type { WorkProfileDTO } from "../../../../common"; import { ApplicationQuickEditor } from "../../create"; -import { applicationApi, applicationKeys } from "../api"; +import { useApplicationConfigurationFormController } from "../controllers"; +import { applicationOptions } from "../helpers"; +import type { ApplicationConfigurationFormProps } from "../types"; import { ApplicationFeedback } from "./application-feedback"; -export function ApplicationConfigurationForm({ - company, - csrfToken, - configuration, - initialProfileId = "", - onSaved, -}: { - company: CompanyDTO; - csrfToken: string; - configuration?: ApplicationConfigurationDTO | undefined; - initialProfileId?: string; - onSaved: () => void; -}) { +function applicationProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) { + return profiles + .filter((item) => item.values.active || item.id === selected) + .map((item) => ({ value: item.id, label: item.values.name })); +} + +export function ApplicationConfigurationForm(props: ApplicationConfigurationFormProps) { + const { company, csrfToken, configuration } = props; + const { + quick, + setQuick, + profiles, + resources, + catalog, + form, + values, + ready, + fixed, + states, + mutation, + submit, + changeDelivery, + changeState, + quickCreated, + } = useApplicationConfigurationFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState<"profile" | "application">(); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const resources = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const catalog = useQuery({ - queryKey: catalogKeys.scope(company.id), - queryFn: () => catalogApi.get(company.id), - }); - const form = useForm({ - resolver: zodResolver(ApplicationConfigurationInputSchema), - defaultValues: applicationDefaults(configuration, initialProfileId), - }); - const values = form.watch(); - const ready = [profiles.data, resources.data, catalog.data].every(Boolean); - const fixed = Boolean(configuration || initialProfileId); - const states = applicationStates(configuration); - const mutation = useMutation({ - mutationFn: (input: ApplicationConfigurationInput) => - applicationApi.save(company.id, input, csrfToken, configuration?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: applicationKeys.company(company.id) }); - if (mounted.current) { - onSaved(); - } - }, - }); return ( <> - mutation.mutate(input))}> +
@@ -136,7 +96,7 @@ export function ApplicationConfigurationForm({ changeDelivery(value, form)} + onValueChange={changeDelivery} options={["installed", "web"].map((value) => ({ value, label: t(`applications.${value}`), @@ -151,12 +111,7 @@ export function ApplicationConfigurationForm({ { - const parsed = ApplicationConfigurationInputSchema.shape.state.safeParse(value); - if (parsed.success) { - form.setValue("state", parsed.data); - } - }} + onValueChange={changeState} options={states.map((value) => ({ value, label: t(`mail.${value}`) }))} placeholder={t("mail.choose")} required @@ -217,76 +172,9 @@ export function ApplicationConfigurationForm({ csrfToken={csrfToken} kind={quick} onClose={() => setQuick(undefined)} - onSaved={(field, id) => { - if (mounted.current) { - form.setValue(field, id); - setQuick(undefined); - } - }} + onSaved={quickCreated} /> )} ); } - -function applicationOptions( - selected: string, - resources: readonly ResourceDTO[] = [], - items: readonly CatalogItemDTO[] = [] -) { - const templates = new Set( - items - .filter( - (item) => - item.values.kind === "template" && - (item.builtinKey === "template:application" || item.values.usage === "application") - ) - .map((item) => item.id) - ); - return resources - .filter( - (item) => - templates.has(item.values.templateId) && (item.values.active || item.id === selected) - ) - .map((item) => ({ value: item.id, label: item.values.name })); -} - -function applicationDefaults( - configuration: ApplicationConfigurationDTO | undefined, - profileId: string -): ApplicationConfigurationInput { - return ( - configuration?.values ?? { - profileId, - resourceId: "", - delivery: "installed", - version: null, - state: "pending", - notes: null, - } - ); -} - -function applicationStates( - configuration: ApplicationConfigurationDTO | undefined -): readonly ApplicationConfigurationInput["state"][] { - return configuration - ? APPLICATION_STATE_TRANSITIONS[configuration.values.state] - : ["pending", "configured"]; -} - -function applicationProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) { - return profiles - .filter((item) => item.values.active || item.id === selected) - .map((item) => ({ value: item.id, label: item.values.name })); -} - -function changeDelivery(value: string, form: UseFormReturn) { - const parsed = ApplicationConfigurationInputSchema.shape.delivery.safeParse(value); - if (parsed.success) { - form.setValue("delivery", parsed.data); - if (parsed.data === "web") { - form.setValue("version", null); - } - } -} diff --git a/modules/companies/src/web/assignments/shared/api/index.ts b/modules/companies/src/web/assignments/shared/api/index.ts new file mode 100644 index 0000000..9bddff1 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/api/index.ts @@ -0,0 +1 @@ +export { saveAssignment } from "./save-assignment.api"; diff --git a/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts b/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts new file mode 100644 index 0000000..5151716 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/api/save-assignment.api.ts @@ -0,0 +1,16 @@ +import { CompanyResponseSchema } from "../../../../common"; +import { companyRequest } from "../../../companies/shared/api/company-request"; + +export const saveAssignment = ( + companyId: string, + personId: string, + id: string | undefined, + body: unknown, + token: string +) => + companyRequest( + `/${companyId}/people/${personId}/assignments${id ? `/${id}/update` : ""}`, + CompanyResponseSchema, + body, + token + ); diff --git a/modules/companies/src/web/assignments/shared/controllers/index.ts b/modules/companies/src/web/assignments/shared/controllers/index.ts new file mode 100644 index 0000000..ed136e1 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useAssignmentFormController } from "./use-assignment-form-controller"; diff --git a/modules/companies/src/web/assignments/shared/controllers/use-assignment-form-controller.ts b/modules/companies/src/web/assignments/shared/controllers/use-assignment-form-controller.ts new file mode 100644 index 0000000..08ec532 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/controllers/use-assignment-form-controller.ts @@ -0,0 +1,53 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import { companyKeys } from "../../../companies/shared/queries"; +import { saveAssignment } from "../api"; +import { today } from "../helpers"; +import { FormSchema } from "../schemas"; +import type { AssignmentFormProps, FormValues } from "../types"; + +export function useAssignmentFormController({ + company, + personId, + assignment, + csrfToken, + onSaved, +}: AssignmentFormProps) { + const [creatingDepartment, setCreatingDepartment] = useState(false); + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + departmentId: assignment?.departmentId ?? "", + position: assignment?.position ?? "", + startDate: assignment?.startDate ?? today(), + endDate: assignment?.endDate ?? "", + isPrimary: assignment?.isPrimary ?? false, + }, + }); + const mutation = useMutation({ + mutationFn: (values: FormValues) => + saveAssignment( + company.id, + personId, + assignment?.id, + { + ...(assignment ? {} : { departmentId: values.departmentId }), + position: values.position.trim() || null, + startDate: values.startDate, + endDate: values.endDate || null, + isPrimary: values.endDate ? false : values.isPrimary, + }, + csrfToken + ), + onSuccess: (response) => { + client.setQueryData(companyKeys.detail(company.id), response); + onSaved(); + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { creatingDepartment, setCreatingDepartment, form, mutation, submit }; +} diff --git a/modules/companies/src/web/assignments/shared/helpers/assignment-form-values.ts b/modules/companies/src/web/assignments/shared/helpers/assignment-form-values.ts new file mode 100644 index 0000000..6c722db --- /dev/null +++ b/modules/companies/src/web/assignments/shared/helpers/assignment-form-values.ts @@ -0,0 +1,8 @@ +export function today() { + const date = new Date(); + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-"); +} diff --git a/modules/companies/src/web/assignments/shared/helpers/index.ts b/modules/companies/src/web/assignments/shared/helpers/index.ts new file mode 100644 index 0000000..90ca311 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/helpers/index.ts @@ -0,0 +1 @@ +export { today } from "./assignment-form-values"; diff --git a/modules/companies/src/web/assignments/shared/schemas/assignment-form.schema.ts b/modules/companies/src/web/assignments/shared/schemas/assignment-form.schema.ts new file mode 100644 index 0000000..449b500 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/schemas/assignment-form.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const FormSchema = z.strictObject({ + departmentId: z.uuid(), + position: z.string().max(100), + startDate: z.iso.date(), + endDate: z.union([z.iso.date(), z.literal("")]), + isPrimary: z.boolean(), +}); diff --git a/modules/companies/src/web/assignments/shared/schemas/index.ts b/modules/companies/src/web/assignments/shared/schemas/index.ts new file mode 100644 index 0000000..b3a99c6 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/schemas/index.ts @@ -0,0 +1 @@ +export { FormSchema } from "./assignment-form.schema"; diff --git a/modules/companies/src/web/assignments/shared/types/assignment-form-props.ts b/modules/companies/src/web/assignments/shared/types/assignment-form-props.ts new file mode 100644 index 0000000..f372b32 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/types/assignment-form-props.ts @@ -0,0 +1,14 @@ +import type { z } from "zod/v4"; + +import type { CompanyDTO, DepartmentAssignmentDTO } from "../../../../common"; +import type { FormSchema } from "../schemas"; + +export type FormValues = z.infer; + +export type AssignmentFormProps = { + company: CompanyDTO; + personId: string; + assignment?: DepartmentAssignmentDTO | undefined; + csrfToken: string; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/assignments/shared/types/index.ts b/modules/companies/src/web/assignments/shared/types/index.ts new file mode 100644 index 0000000..6406330 --- /dev/null +++ b/modules/companies/src/web/assignments/shared/types/index.ts @@ -0,0 +1 @@ +export type { AssignmentFormProps, FormValues } from "./assignment-form-props"; diff --git a/modules/companies/src/web/assignments/shared/ui/assignment-form.tsx b/modules/companies/src/web/assignments/shared/ui/assignment-form.tsx index 3981a89..7686020 100644 --- a/modules/companies/src/web/assignments/shared/ui/assignment-form.tsx +++ b/modules/companies/src/web/assignments/shared/ui/assignment-form.tsx @@ -1,87 +1,27 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { CompanyDTO, DepartmentAssignmentDTO } from "../../../../common"; import { EntityHelp } from "../../../access/help"; -import { CompanyFeedback, companyApi, companyKeys } from "../../../companies/shared"; +import { CompanyFeedback } from "../../../companies/shared"; import { DepartmentEditorDialog } from "../../../departments"; +import { useAssignmentFormController } from "../controllers"; +import type { AssignmentFormProps } from "../types"; -const FormSchema = z.strictObject({ - departmentId: z.uuid(), - position: z.string().max(100), - startDate: z.iso.date(), - endDate: z.union([z.iso.date(), z.literal("")]), - isPrimary: z.boolean(), -}); -type FormValues = z.infer; -function today() { - const date = new Date(); - return [ - date.getFullYear(), - String(date.getMonth() + 1).padStart(2, "0"), - String(date.getDate()).padStart(2, "0"), - ].join("-"); -} -export function AssignmentForm({ - company, - personId, - assignment, - csrfToken, - onSaved, -}: { - company: CompanyDTO; - personId: string; - assignment?: DepartmentAssignmentDTO | undefined; - csrfToken: string; - onSaved: () => void; -}) { +export function AssignmentForm(props: AssignmentFormProps) { + const { company, assignment, csrfToken } = props; + const { creatingDepartment, setCreatingDepartment, form, mutation, submit } = + useAssignmentFormController(props); const { t } = useTranslation("companies"); const id = useId(); - const [creatingDepartment, setCreatingDepartment] = useState(false); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - departmentId: assignment?.departmentId ?? "", - position: assignment?.position ?? "", - startDate: assignment?.startDate ?? today(), - endDate: assignment?.endDate ?? "", - isPrimary: assignment?.isPrimary ?? false, - }, - }); - const mutation = useMutation({ - mutationFn: (values: FormValues) => - companyApi.assignment( - company.id, - personId, - assignment?.id, - { - ...(assignment ? {} : { departmentId: values.departmentId }), - position: values.position.trim() || null, - startDate: values.startDate, - endDate: values.endDate || null, - isPrimary: values.endDate ? false : values.isPrimary, - }, - csrfToken - ), - onSuccess: (response) => { - client.setQueryData(companyKeys.detail(company.id), response); - onSaved(); - }, - }); return ( <> - mutation.mutate(values))}> +
(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: certificateKeys.company(company.id), + queryFn: () => certificateApi.get(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const resources = useQuery({ + queryKey: resourceKeys.company(company.id), + queryFn: () => resourceApi.get(company.id), + }); + const resource = (id: string) => + resources.data?.resources.find((item) => item.id === id)?.values.name ?? id; + const configurations = query.data?.configurations ?? []; + const asset = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id); + const label = (id: string) => { + const item = profile(id); + return item + ? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}` + : id; + }; + const filtered = configurations.filter( + (item) => + (!initialProfileId || item.values.profileId === initialProfileId) && + `${resource(item.values.resourceId)} ${label(item.values.profileId)}` + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()) + ); + const edited = configurations.find((item) => item.id === editor); + const viewed = configurations.find((item) => item.id === view); + const ready = [query.data, profiles.data, inventory.data, resources.data].every(Boolean); + const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; + return { + editor, + setEditor, + setView, + search, + setSearch, + query, + profiles, + inventory, + resources, + resource, + label, + filtered, + edited, + viewed, + ready, + canCreate, + }; +} diff --git a/modules/companies/src/web/certificate-configurations/list/types/certificate-configurations-page-props.ts b/modules/companies/src/web/certificate-configurations/list/types/certificate-configurations-page-props.ts new file mode 100644 index 0000000..f987e19 --- /dev/null +++ b/modules/companies/src/web/certificate-configurations/list/types/certificate-configurations-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type CertificateConfigurationsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/certificate-configurations/list/types/index.ts b/modules/companies/src/web/certificate-configurations/list/types/index.ts new file mode 100644 index 0000000..c4c5835 --- /dev/null +++ b/modules/companies/src/web/certificate-configurations/list/types/index.ts @@ -0,0 +1 @@ +export type { CertificateConfigurationsPageProps } from "./certificate-configurations-page-props"; diff --git a/modules/companies/src/web/certificate-configurations/list/ui/certificate-configurations-page.tsx b/modules/companies/src/web/certificate-configurations/list/ui/certificate-configurations-page.tsx index dc39da0..5efb5e4 100644 --- a/modules/companies/src/web/certificate-configurations/list/ui/certificate-configurations-page.tsx +++ b/modules/companies/src/web/certificate-configurations/list/ui/certificate-configurations-page.tsx @@ -1,8 +1,3 @@ -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; -import { - inventoryApi as resourceApi, - inventoryKeys as resourceKeys, -} from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -11,72 +6,34 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { CertificateHistoryDialog } from "../../history"; -import { - CertificateConfigurationEditorDialog, - CertificateFeedback, - certificateApi, - certificateKeys, -} from "../../shared"; +import { CertificateConfigurationEditorDialog, CertificateFeedback } from "../../shared"; +import { useCertificateConfigurationsPageController } from "../controllers"; +import type { CertificateConfigurationsPageProps } from "../types"; -export function CertificateConfigurationsPage({ - company, - csrfToken, - initialProfileId, -}: { - company: CompanyDTO; - csrfToken: string; - initialProfileId?: string; -}) { +export function CertificateConfigurationsPage(props: CertificateConfigurationsPageProps) { + const { company, csrfToken, initialProfileId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + query, + profiles, + inventory, + resources, + resource, + label, + filtered, + edited, + viewed, + ready, + canCreate, + } = useCertificateConfigurationsPageController(props); const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: certificateKeys.company(company.id), - queryFn: () => certificateApi.get(company.id), - }); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const resources = useQuery({ - queryKey: resourceKeys.company(company.id), - queryFn: () => resourceApi.get(company.id), - }); - const resource = (id: string) => - resources.data?.resources.find((item) => item.id === id)?.values.name ?? id; - const configurations = query.data?.configurations ?? []; - const asset = (id: string) => - inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; - const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id); - const label = (id: string) => { - const item = profile(id); - return item - ? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}` - : id; - }; - const filtered = configurations.filter( - (item) => - (!initialProfileId || item.values.profileId === initialProfileId) && - `${resource(item.values.resourceId)} ${label(item.values.profileId)}` - .toLocaleLowerCase() - .includes(search.trim().toLocaleLowerCase()) - ); - const edited = configurations.find((item) => item.id === editor); - const viewed = configurations.find((item) => item.id === view); - const ready = [query.data, profiles.data, inventory.data, resources.data].every(Boolean); - const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState<"profile" | "certificate">(); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const resources = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const catalog = useQuery({ + queryKey: catalogKeys.scope(company.id), + queryFn: () => catalogApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(CertificateConfigurationInputSchema), + defaultValues: certificateDefaults(configuration, initialProfileId), + }); + const values = form.watch(); + const ready = [profiles.data, resources.data, catalog.data].every(Boolean); + const fixed = Boolean(configuration || initialProfileId); + const states = certificateStates(configuration); + const mutation = useMutation({ + mutationFn: (input: CertificateConfigurationInput) => + certificateApi.save(company.id, input, csrfToken, configuration?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: certificateKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const changeState = (value: string) => { + const parsed = CertificateConfigurationInputSchema.shape.state.safeParse(value); + if (parsed.success) { + form.setValue("state", parsed.data); + } + }; + const quickCreated = (field: "profileId" | "resourceId", id: string) => { + if (mounted.current) { + form.setValue(field, id); + setQuick(undefined); + } + }; + return { + quick, + setQuick, + profiles, + resources, + catalog, + form, + values, + ready, + fixed, + states, + mutation, + submit, + changeState, + quickCreated, + }; +} diff --git a/modules/companies/src/web/certificate-configurations/shared/helpers/certificate-configuration-form-values.ts b/modules/companies/src/web/certificate-configurations/shared/helpers/certificate-configuration-form-values.ts new file mode 100644 index 0000000..b1e44e3 --- /dev/null +++ b/modules/companies/src/web/certificate-configurations/shared/helpers/certificate-configuration-form-values.ts @@ -0,0 +1,43 @@ +import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common"; + +import type { + CertificateConfigurationDTO, + CertificateConfigurationInput, +} from "../../../../common/certificate-configurations"; + +export function certificateOptions( + selected: string, + resources: readonly ResourceDTO[] = [], + items: readonly CatalogItemDTO[] = [] +) { + const templates = new Set( + items + .filter( + (item) => + item.values.kind === "template" && + (item.builtinKey === "template:certificate" || item.values.usage === "certificate") + ) + .map((item) => item.id) + ); + return resources + .filter( + (item) => + templates.has(item.values.templateId) && (item.values.active || item.id === selected) + ) + .map((item) => ({ value: item.id, label: item.values.name })); +} + +export function certificateDefaults( + configuration: CertificateConfigurationDTO | undefined, + profileId: string +): CertificateConfigurationInput { + return ( + configuration?.values ?? { + profileId, + resourceId: "", + location: null, + state: "pending", + notes: null, + } + ); +} diff --git a/modules/companies/src/web/certificate-configurations/shared/helpers/index.ts b/modules/companies/src/web/certificate-configurations/shared/helpers/index.ts new file mode 100644 index 0000000..f7c357b --- /dev/null +++ b/modules/companies/src/web/certificate-configurations/shared/helpers/index.ts @@ -0,0 +1 @@ +export { certificateDefaults, certificateOptions } from "./certificate-configuration-form-values"; diff --git a/modules/companies/src/web/certificate-configurations/shared/types/certificate-configuration-form-props.ts b/modules/companies/src/web/certificate-configurations/shared/types/certificate-configuration-form-props.ts new file mode 100644 index 0000000..a9b02b5 --- /dev/null +++ b/modules/companies/src/web/certificate-configurations/shared/types/certificate-configuration-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { CertificateConfigurationDTO } from "../../../../common/certificate-configurations"; + +export type CertificateConfigurationFormProps = { + company: CompanyDTO; + csrfToken: string; + configuration?: CertificateConfigurationDTO | undefined; + initialProfileId?: string; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/certificate-configurations/shared/types/index.ts b/modules/companies/src/web/certificate-configurations/shared/types/index.ts new file mode 100644 index 0000000..40ab06a --- /dev/null +++ b/modules/companies/src/web/certificate-configurations/shared/types/index.ts @@ -0,0 +1 @@ +export type { CertificateConfigurationFormProps } from "./certificate-configuration-form-props"; diff --git a/modules/companies/src/web/certificate-configurations/shared/ui/certificate-configuration-form.tsx b/modules/companies/src/web/certificate-configurations/shared/ui/certificate-configuration-form.tsx index da4f1ce..74f8135 100644 --- a/modules/companies/src/web/certificate-configurations/shared/ui/certificate-configuration-form.tsx +++ b/modules/companies/src/web/certificate-configurations/shared/ui/certificate-configuration-form.tsx @@ -1,6 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { catalogApi, catalogKeys, inventoryApi, inventoryKeys } from "@metis/resources/client"; -import type { CatalogItemDTO, ResourceDTO } from "@metis/resources/common"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -8,83 +5,46 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO, WorkProfileDTO } from "../../../../common"; -import type { - CertificateConfigurationDTO, - CertificateConfigurationInput, -} from "../../../../common/certificate-configurations"; -import { - CERTIFICATE_STATE_TRANSITIONS, - CertificateConfigurationInputSchema, -} from "../../../../common/certificate-configurations"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; +import type { WorkProfileDTO } from "../../../../common"; import { CertificateQuickEditor } from "../../create"; -import { certificateApi, certificateKeys } from "../api"; +import { useCertificateConfigurationFormController } from "../controllers"; +import { certificateOptions } from "../helpers"; +import type { CertificateConfigurationFormProps } from "../types"; import { CertificateFeedback } from "./certificate-feedback"; -export function CertificateConfigurationForm({ - company, - csrfToken, - configuration, - initialProfileId = "", - onSaved, -}: { - company: CompanyDTO; - csrfToken: string; - configuration?: CertificateConfigurationDTO | undefined; - initialProfileId?: string; - onSaved: () => void; -}) { +function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) { + return profiles + .filter((item) => item.values.active || item.id === selected) + .map((item) => ({ value: item.id, label: item.values.name })); +} + +export function CertificateConfigurationForm(props: CertificateConfigurationFormProps) { + const { company, csrfToken, configuration } = props; + const { + quick, + setQuick, + profiles, + resources, + catalog, + form, + values, + ready, + fixed, + states, + mutation, + submit, + changeState, + quickCreated, + } = useCertificateConfigurationFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState<"profile" | "certificate">(); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const resources = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const catalog = useQuery({ - queryKey: catalogKeys.scope(company.id), - queryFn: () => catalogApi.get(company.id), - }); - const form = useForm({ - resolver: zodResolver(CertificateConfigurationInputSchema), - defaultValues: certificateDefaults(configuration, initialProfileId), - }); - const values = form.watch(); - const ready = [profiles.data, resources.data, catalog.data].every(Boolean); - const fixed = Boolean(configuration || initialProfileId); - const states = certificateStates(configuration); - const mutation = useMutation({ - mutationFn: (input: CertificateConfigurationInput) => - certificateApi.save(company.id, input, csrfToken, configuration?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: certificateKeys.company(company.id) }); - if (mounted.current) { - onSaved(); - } - }, - }); return ( <> - mutation.mutate(input))}> +
@@ -135,12 +95,7 @@ export function CertificateConfigurationForm({ { - const parsed = CertificateConfigurationInputSchema.shape.state.safeParse(value); - if (parsed.success) { - form.setValue("state", parsed.data); - } - }} + onValueChange={changeState} options={states.map((value) => ({ value, label: t(`mail.${value}`) }))} placeholder={t("mail.choose")} required @@ -199,65 +154,9 @@ export function CertificateConfigurationForm({ csrfToken={csrfToken} kind={quick} onClose={() => setQuick(undefined)} - onSaved={(field, id) => { - if (mounted.current) { - form.setValue(field, id); - setQuick(undefined); - } - }} + onSaved={quickCreated} /> )} ); } - -function certificateOptions( - selected: string, - resources: readonly ResourceDTO[] = [], - items: readonly CatalogItemDTO[] = [] -) { - const templates = new Set( - items - .filter( - (item) => - item.values.kind === "template" && - (item.builtinKey === "template:certificate" || item.values.usage === "certificate") - ) - .map((item) => item.id) - ); - return resources - .filter( - (item) => - templates.has(item.values.templateId) && (item.values.active || item.id === selected) - ) - .map((item) => ({ value: item.id, label: item.values.name })); -} - -function certificateDefaults( - configuration: CertificateConfigurationDTO | undefined, - profileId: string -): CertificateConfigurationInput { - return ( - configuration?.values ?? { - profileId, - resourceId: "", - location: null, - state: "pending", - notes: null, - } - ); -} - -function certificateStates( - configuration: CertificateConfigurationDTO | undefined -): readonly CertificateConfigurationInput["state"][] { - return configuration - ? CERTIFICATE_STATE_TRANSITIONS[configuration.values.state] - : ["pending", "configured"]; -} - -function certificateProfiles(selected: string, profiles: readonly WorkProfileDTO[] = []) { - return profiles - .filter((item) => item.values.active || item.id === selected) - .map((item) => ({ value: item.id, label: item.values.name })); -} diff --git a/modules/companies/src/web/companies/change-logo/api/company-logo-api.ts b/modules/companies/src/web/companies/change-logo/api/company-logo-api.ts index ad3eb73..6ea72eb 100644 --- a/modules/companies/src/web/companies/change-logo/api/company-logo-api.ts +++ b/modules/companies/src/web/companies/change-logo/api/company-logo-api.ts @@ -1,5 +1,5 @@ import { CompanyErrorResponseSchema, CompanyLogoResponseSchema } from "../../../../common"; -import { CompanyApiError } from "../../shared/api"; +import { CompanyApiError } from "../../shared/api/company-api-error"; export async function saveCompanyLogo(companyId: string, logo: File | null, csrfToken: string) { const response = await fetch(`/api/companies/${companyId}/logo${logo ? "" : "/remove"}`, { method: "POST", diff --git a/modules/companies/src/web/companies/change-logo/helpers/index.ts b/modules/companies/src/web/companies/change-logo/helpers/index.ts new file mode 100644 index 0000000..ccbf8dd --- /dev/null +++ b/modules/companies/src/web/companies/change-logo/helpers/index.ts @@ -0,0 +1 @@ +export { validateCompanyLogo } from "./validate-company-logo"; diff --git a/modules/companies/src/web/companies/change-logo/helpers/validate-company-logo.ts b/modules/companies/src/web/companies/change-logo/helpers/validate-company-logo.ts new file mode 100644 index 0000000..b1be3f2 --- /dev/null +++ b/modules/companies/src/web/companies/change-logo/helpers/validate-company-logo.ts @@ -0,0 +1,13 @@ +import { COMPANY_LOGO_LIMITS, COMPANY_LOGO_TYPES } from "../../../../common"; + +export function validateCompanyLogo( + file: Pick +): "INVALID_LOGO" | "LOGO_TOO_LARGE" | null { + if (!COMPANY_LOGO_TYPES.some((type) => type === file.type)) { + return "INVALID_LOGO"; + } + if (file.size > COMPANY_LOGO_LIMITS.inputBytes || !file.size) { + return "LOGO_TOO_LARGE"; + } + return null; +} diff --git a/modules/companies/src/web/companies/change-logo/ui/company-logo-field.tsx b/modules/companies/src/web/companies/change-logo/ui/company-logo-field.tsx index ae1a882..21fad24 100644 --- a/modules/companies/src/web/companies/change-logo/ui/company-logo-field.tsx +++ b/modules/companies/src/web/companies/change-logo/ui/company-logo-field.tsx @@ -4,8 +4,9 @@ import { Label } from "@repo/shadcn-ui/components/label"; import { useId, useState } from "react"; import { useTranslation } from "react-i18next"; -import { COMPANY_LOGO_LIMITS, COMPANY_LOGO_TYPES } from "../../../../common"; +import { COMPANY_LOGO_TYPES } from "../../../../common"; import { CompanyAvatar } from "../../shared/ui/company-avatar"; +import { validateCompanyLogo } from "../helpers"; export function CompanyLogoField({ companyId, name, @@ -55,12 +56,9 @@ export function CompanyLogoField({ if (!file) { return; } - if (!COMPANY_LOGO_TYPES.some((type) => type === file.type)) { - setError(t("errors.INVALID_LOGO")); - return; - } - if (file.size > COMPANY_LOGO_LIMITS.inputBytes || !file.size) { - setError(t("errors.LOGO_TOO_LARGE")); + const validation = validateCompanyLogo(file); + if (validation) { + setError(t(`errors.${validation}`)); return; } setError(null); diff --git a/modules/companies/src/web/companies/detail/api/get-company.api.ts b/modules/companies/src/web/companies/detail/api/get-company.api.ts new file mode 100644 index 0000000..3220ef4 --- /dev/null +++ b/modules/companies/src/web/companies/detail/api/get-company.api.ts @@ -0,0 +1,4 @@ +import { CompanyResponseSchema } from "../../../../common"; +import { companyRequest } from "../../shared/api/company-request"; + +export const getCompany = (id: string) => companyRequest(`/${id}`, CompanyResponseSchema); diff --git a/modules/companies/src/web/companies/detail/api/index.ts b/modules/companies/src/web/companies/detail/api/index.ts new file mode 100644 index 0000000..c1163c7 --- /dev/null +++ b/modules/companies/src/web/companies/detail/api/index.ts @@ -0,0 +1 @@ +export { getCompany } from "./get-company.api"; diff --git a/modules/companies/src/web/companies/detail/controllers/index.ts b/modules/companies/src/web/companies/detail/controllers/index.ts new file mode 100644 index 0000000..006b8de --- /dev/null +++ b/modules/companies/src/web/companies/detail/controllers/index.ts @@ -0,0 +1 @@ +export { useCompanyDetailController } from "./use-company-detail-controller"; diff --git a/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts b/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts new file mode 100644 index 0000000..326f6a1 --- /dev/null +++ b/modules/companies/src/web/companies/detail/controllers/use-company-detail-controller.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { companyKeys } from "../../shared/queries"; +import type { CompanyTab } from "../../shared/types"; +import { getCompany } from "../api"; +import type { CompanyDetailProps } from "../types"; + +export function useCompanyDetailController({ + id, + embedded = false, + initialSection, +}: CompanyDetailProps) { + const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => getCompany(id) }); + const [editOpen, setEditOpen] = useState(false); + const [tab, setTab] = useState(initialSection ?? (embedded ? "inventory" : "people")); + const company = query.data?.company; + + return { query, editOpen, setEditOpen, tab, setTab, company }; +} diff --git a/modules/companies/src/web/companies/detail/types/company-detail-props.ts b/modules/companies/src/web/companies/detail/types/company-detail-props.ts new file mode 100644 index 0000000..ff28788 --- /dev/null +++ b/modules/companies/src/web/companies/detail/types/company-detail-props.ts @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +import type { CompanyTab } from "../../shared/types"; + +export type CompanyDetailProps = { + id: string; + csrfToken: string; + onBack: () => void; + embedded?: boolean; + accountNavigation?: ReactNode; + initialSection?: CompanyTab; + initialEntityId?: string | undefined; + initialCreate?: boolean | undefined; +}; diff --git a/modules/companies/src/web/companies/detail/types/index.ts b/modules/companies/src/web/companies/detail/types/index.ts new file mode 100644 index 0000000..a3d2f47 --- /dev/null +++ b/modules/companies/src/web/companies/detail/types/index.ts @@ -0,0 +1 @@ +export type { CompanyDetailProps } from "./company-detail-props"; diff --git a/modules/companies/src/web/companies/detail/ui/company-detail.tsx b/modules/companies/src/web/companies/detail/ui/company-detail.tsx index e894db8..4d80454 100644 --- a/modules/companies/src/web/companies/detail/ui/company-detail.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-detail.tsx @@ -9,49 +9,29 @@ import { MapPinIcon, UsersIcon, } from "@repo/shadcn-ui/lib/icons"; -import { useQuery } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyTab } from "../../shared"; -import { - CompanyEditorDialog, - CompanyFeedback, - CompanyIdentity, - companyApi, - companyKeys, -} from "../../shared"; +import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared"; +import { useCompanyDetailController } from "../controllers"; +import type { CompanyDetailProps } from "../types"; import { CompanySection } from "./company-section"; -export function CompanyDetail({ - id, - csrfToken, - onBack, - embedded = false, - accountNavigation, - initialSection, - initialEntityId, - initialCreate, -}: { - id: string; - csrfToken: string; - onBack: () => void; - embedded?: boolean; - accountNavigation?: ReactNode; - initialSection?: CompanyTab; - initialEntityId?: string | undefined; - initialCreate?: boolean | undefined; -}) { +export function CompanyDetail(props: CompanyDetailProps) { + const { + csrfToken, + onBack, + embedded = false, + accountNavigation, + initialSection, + initialEntityId, + initialCreate, + } = props; + const { query, editOpen, setEditOpen, tab, setTab, company } = useCompanyDetailController(props); const { t } = useTranslation("companies"); const { t: resourceTranslation } = useTranslation("resources"); const { t: assetTranslation } = useTranslation("assets"); const { t: accessTranslation } = useTranslation("access"); - const query = useQuery({ queryKey: companyKeys.detail(id), queryFn: () => companyApi.get(id) }); - const [editOpen, setEditOpen] = useState(false); - const [tab, setTab] = useState(initialSection ?? (embedded ? "inventory" : "people")); - const company = query.data?.company; const layout = embedded ? { root: "grid min-h-[calc(100dvh-140px)] grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_1fr_auto] [grid-template-areas:'heading'_'navigation'_'main'_'account'] shell:min-h-[calc(100dvh-var(--spacing-shell-header))] shell:grid-cols-[240px_minmax(0,1fr)] shell:grid-rows-[auto_1fr_auto] shell:[grid-template-areas:'heading_main'_'navigation_main'_'account_main']", @@ -67,7 +47,6 @@ export function CompanyDetail({ navigation: "flex flex-wrap gap-2", main: undefined, }; - return (
{!embedded && ( diff --git a/modules/companies/src/web/companies/detail/ui/company-section.tsx b/modules/companies/src/web/companies/detail/ui/company-section.tsx index 8d4f1d7..17fd2ef 100644 --- a/modules/companies/src/web/companies/detail/ui/company-section.tsx +++ b/modules/companies/src/web/companies/detail/ui/company-section.tsx @@ -19,7 +19,7 @@ import { SiteSection } from "../../../sites"; import { TeleworkAuthorizationsPage } from "../../../telework-authorizations"; import { TicketsPage } from "../../../tickets"; import { WorkProfilesPage } from "../../../work-profiles"; -import type { CompanyTab } from "../../shared"; +import type { CompanyTab } from "../../shared/types"; import { CompanyInventory } from "./company-inventory"; diff --git a/modules/companies/src/web/companies/index.ts b/modules/companies/src/web/companies/index.ts index 63b5467..bb5fa2c 100644 --- a/modules/companies/src/web/companies/index.ts +++ b/modules/companies/src/web/companies/index.ts @@ -7,6 +7,5 @@ export { CompanyForm, CompanyIdentity, CompanySelect, - companyApi, - companyKeys, } from "./shared"; +export { companyKeys } from "./shared/queries"; diff --git a/modules/companies/src/web/companies/list/api/index.ts b/modules/companies/src/web/companies/list/api/index.ts new file mode 100644 index 0000000..4e642a7 --- /dev/null +++ b/modules/companies/src/web/companies/list/api/index.ts @@ -0,0 +1 @@ +export { listCompanies } from "./list-companies.api"; diff --git a/modules/companies/src/web/companies/list/api/list-companies.api.ts b/modules/companies/src/web/companies/list/api/list-companies.api.ts new file mode 100644 index 0000000..41f160b --- /dev/null +++ b/modules/companies/src/web/companies/list/api/list-companies.api.ts @@ -0,0 +1,4 @@ +import { CompaniesResponseSchema } from "../../../../common"; +import { companyRequest } from "../../shared/api/company-request"; + +export const listCompanies = () => companyRequest("", CompaniesResponseSchema); diff --git a/modules/companies/src/web/companies/list/controllers/index.ts b/modules/companies/src/web/companies/list/controllers/index.ts new file mode 100644 index 0000000..a352618 --- /dev/null +++ b/modules/companies/src/web/companies/list/controllers/index.ts @@ -0,0 +1 @@ +export { useCompaniesPageController } from "./use-companies-page-controller"; diff --git a/modules/companies/src/web/companies/list/controllers/use-companies-page-controller.ts b/modules/companies/src/web/companies/list/controllers/use-companies-page-controller.ts new file mode 100644 index 0000000..fc8083c --- /dev/null +++ b/modules/companies/src/web/companies/list/controllers/use-companies-page-controller.ts @@ -0,0 +1,52 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ColumnDef } from "@tanstack/react-table"; +import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table"; +import { useState } from "react"; + +import type { CompanySummaryDTO } from "../../../../common"; +import { companyKeys } from "../../shared/queries"; +import { listCompanies } from "../api"; +import type { CompaniesPageProps } from "../types"; + +const EMPTY_COMPANIES: CompanySummaryDTO[] = []; +const COMPANY_COLUMNS: ColumnDef[] = [ + { accessorKey: "name" }, + { accessorKey: "active", enableGlobalFilter: false }, +]; +export function useCompaniesPageController({ + onSelectCompany, + initialActiveOnly = false, + initialCompanyId, + initialCreate = false, +}: CompaniesPageProps) { + const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies }); + const [selectedId, setSelectedId] = useState(); + const [companyId, setCompanyId] = useState(initialCompanyId); + const [activeOnly, setActiveOnly] = useState(initialActiveOnly); + const [createOpen, setCreateOpen] = useState(initialCreate); + const [search, setSearch] = useState(""); + const table = useReactTable({ + data: query.data?.companies ?? EMPTY_COMPANIES, + columns: COMPANY_COLUMNS, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { globalFilter: search.trim() }, + globalFilterFn: "includesString", + }); + const selectCompany = onSelectCompany ?? setSelectedId; + return { + query, + selectedId, + setSelectedId, + companyId, + setCompanyId, + activeOnly, + setActiveOnly, + createOpen, + setCreateOpen, + search, + setSearch, + table, + selectCompany, + }; +} diff --git a/modules/companies/src/web/companies/list/types/companies-page-props.ts b/modules/companies/src/web/companies/list/types/companies-page-props.ts new file mode 100644 index 0000000..8536ff5 --- /dev/null +++ b/modules/companies/src/web/companies/list/types/companies-page-props.ts @@ -0,0 +1,7 @@ +export type CompaniesPageProps = { + csrfToken: string; + onSelectCompany?: (id: string) => void; + initialActiveOnly?: boolean | undefined; + initialCompanyId?: string | undefined; + initialCreate?: boolean | undefined; +}; diff --git a/modules/companies/src/web/companies/list/types/index.ts b/modules/companies/src/web/companies/list/types/index.ts new file mode 100644 index 0000000..7650b10 --- /dev/null +++ b/modules/companies/src/web/companies/list/types/index.ts @@ -0,0 +1 @@ +export type { CompaniesPageProps } from "./companies-page-props"; diff --git a/modules/companies/src/web/companies/list/ui/companies-page.tsx b/modules/companies/src/web/companies/list/ui/companies-page.tsx index c311e9e..b498ab9 100644 --- a/modules/companies/src/web/companies/list/ui/companies-page.tsx +++ b/modules/companies/src/web/companies/list/ui/companies-page.tsx @@ -6,57 +6,31 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import type { ColumnDef } from "@tanstack/react-table"; -import { getCoreRowModel, getFilteredRowModel, useReactTable } from "@tanstack/react-table"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanySummaryDTO } from "../../../../common"; import { CompanyDetail } from "../../detail"; -import { - CompanyEditorDialog, - CompanyFeedback, - CompanyIdentity, - companyApi, - companyKeys, -} from "../../shared"; +import { CompanyEditorDialog, CompanyFeedback, CompanyIdentity } from "../../shared"; +import { useCompaniesPageController } from "../controllers"; +import type { CompaniesPageProps } from "../types"; -const EMPTY_COMPANIES: CompanySummaryDTO[] = []; -const COMPANY_COLUMNS: ColumnDef[] = [ - { accessorKey: "name" }, - { accessorKey: "active", enableGlobalFilter: false }, -]; - -export function CompaniesPage({ - csrfToken, - onSelectCompany, - initialActiveOnly = false, - initialCompanyId, - initialCreate = false, -}: { - csrfToken: string; - onSelectCompany?: (id: string) => void; - initialActiveOnly?: boolean | undefined; - initialCompanyId?: string | undefined; - initialCreate?: boolean | undefined; -}) { +export function CompaniesPage(props: CompaniesPageProps) { + const { csrfToken, onSelectCompany } = props; + const { + query, + selectedId, + setSelectedId, + companyId, + setCompanyId, + activeOnly, + setActiveOnly, + createOpen, + setCreateOpen, + search, + setSearch, + table, + selectCompany, + } = useCompaniesPageController(props); const { t, i18n } = useTranslation("companies"); - const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); - const [selectedId, setSelectedId] = useState(); - const [companyId, setCompanyId] = useState(initialCompanyId); - const [activeOnly, setActiveOnly] = useState(initialActiveOnly); - const [createOpen, setCreateOpen] = useState(initialCreate); - const [search, setSearch] = useState(""); - const table = useReactTable({ - data: query.data?.companies ?? EMPTY_COMPANIES, - columns: COMPANY_COLUMNS, - getCoreRowModel: getCoreRowModel(), - getFilteredRowModel: getFilteredRowModel(), - state: { globalFilter: search.trim() }, - globalFilterFn: "includesString", - }); - const selectCompany = onSelectCompany ?? setSelectedId; if (selectedId && !onSelectCompany) { return ( ["companies", "detail", id], -}; -export class CompanyApiError extends Error { - constructor(public readonly code: string) { - super(code); - } -} -export async function companyRequest( - path: string, - schema: z.ZodType, - body?: unknown, - csrfToken?: string -): Promise { - const response = await fetch(`/api/companies${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 json: unknown = await response.json(); - if (!response.ok) { - const error = CompanyErrorResponseSchema.safeParse(json); - throw new CompanyApiError(error.success ? error.data.code : "INTERNAL_ERROR"); - } - return schema.parse(json); -} -export const companyApi = { - list: () => companyRequest("", CompaniesResponseSchema), - get: (id: string) => companyRequest(`/${id}`, CompanyResponseSchema), - save: (id: string | undefined, body: unknown, token: string) => - companyRequest(id ? `/${id}/update` : "", CompanyResponseSchema, body, token), - person: (companyId: string, id: string | undefined, body: unknown, token: string) => - companyRequest( - `/${companyId}/people${id ? `/${id}/update` : ""}`, - CompanyResponseSchema, - body, - token - ), - department: (companyId: string, id: string | undefined, body: unknown, token: string) => - companyRequest( - `/${companyId}/departments${id ? `/${id}/update` : ""}`, - CompanyResponseSchema, - body, - token - ), - assignment: ( - companyId: string, - personId: string, - id: string | undefined, - body: unknown, - token: string - ) => - companyRequest( - `/${companyId}/people/${personId}/assignments${id ? `/${id}/update` : ""}`, - CompanyResponseSchema, - body, - token - ), - site: (companyId: string, id: string | undefined, body: unknown, token: string) => - companyRequest( - `/${companyId}/sites${id ? `/${id}/update` : ""}`, - CompanyResponseSchema, - body, - token - ), -}; diff --git a/modules/companies/src/web/companies/shared/api/company-request.ts b/modules/companies/src/web/companies/shared/api/company-request.ts new file mode 100644 index 0000000..321ed88 --- /dev/null +++ b/modules/companies/src/web/companies/shared/api/company-request.ts @@ -0,0 +1,28 @@ +import type { z } from "zod/v4"; + +import { CompanyErrorResponseSchema } from "../../../../common"; + +import { CompanyApiError } from "./company-api-error"; + +export async function companyRequest( + path: string, + schema: z.ZodType, + body?: unknown, + csrfToken?: string +): Promise { + const response = await fetch(`/api/companies${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 json: unknown = await response.json(); + if (!response.ok) { + const error = CompanyErrorResponseSchema.safeParse(json); + throw new CompanyApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return schema.parse(json); +} diff --git a/modules/companies/src/web/companies/shared/api/index.ts b/modules/companies/src/web/companies/shared/api/index.ts index 4000eb6..d2a3916 100644 --- a/modules/companies/src/web/companies/shared/api/index.ts +++ b/modules/companies/src/web/companies/shared/api/index.ts @@ -1 +1,2 @@ -export { CompanyApiError, companyApi, companyKeys } from "./company-api"; +export { CompanyApiError } from "./company-api-error"; +export { saveCompany } from "./save-company.api"; diff --git a/modules/companies/src/web/companies/shared/api/save-company.api.ts b/modules/companies/src/web/companies/shared/api/save-company.api.ts new file mode 100644 index 0000000..66b12c6 --- /dev/null +++ b/modules/companies/src/web/companies/shared/api/save-company.api.ts @@ -0,0 +1,6 @@ +import { CompanyResponseSchema } from "../../../../common"; + +import { companyRequest } from "./company-request"; + +export const saveCompany = (id: string | undefined, body: unknown, token: string) => + companyRequest(id ? `/${id}/update` : "", CompanyResponseSchema, body, token); diff --git a/modules/companies/src/web/companies/shared/companies-page.test.tsx b/modules/companies/src/web/companies/shared/companies-page.test.tsx index a36f2c1..e95a161 100644 --- a/modules/companies/src/web/companies/shared/companies-page.test.tsx +++ b/modules/companies/src/web/companies/shared/companies-page.test.tsx @@ -14,7 +14,7 @@ import { SiteForm } from "../../sites"; import { CompanyDetail } from "../detail"; import { CompaniesPage } from "../list"; -import { companyKeys } from "./api"; +import { companyKeys } from "./queries"; const companyId = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4"; async function renderPage( diff --git a/modules/companies/src/web/companies/shared/company-avatar.test.tsx b/modules/companies/src/web/companies/shared/company-avatar.test.tsx index f159a8f..3c1b8a7 100644 --- a/modules/companies/src/web/companies/shared/company-avatar.test.tsx +++ b/modules/companies/src/web/companies/shared/company-avatar.test.tsx @@ -4,7 +4,7 @@ import { test } from "node:test"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderToStaticMarkup } from "react-dom/server"; -import { companyKeys } from "./api"; +import { companyKeys } from "./queries"; import { CompanyAvatar } from "./ui"; const companyId = "0199b200-0000-7000-8000-000000000001"; diff --git a/modules/companies/src/web/companies/shared/controllers/index.ts b/modules/companies/src/web/companies/shared/controllers/index.ts new file mode 100644 index 0000000..620c9a9 --- /dev/null +++ b/modules/companies/src/web/companies/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useCompanyFormController } from "./use-company-form-controller"; diff --git a/modules/companies/src/web/companies/shared/controllers/use-company-form-controller.ts b/modules/companies/src/web/companies/shared/controllers/use-company-form-controller.ts new file mode 100644 index 0000000..3491f2c --- /dev/null +++ b/modules/companies/src/web/companies/shared/controllers/use-company-form-controller.ts @@ -0,0 +1,60 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { CompanyRequestDTO } from "../../../../common"; +import { CompanyRequestSchema } from "../../../../common"; +import { saveCompanyLogo } from "../../change-logo/api"; +import { saveCompany } from "../api"; +import { companyKeys } from "../queries"; +import type { CompanyFormProps } from "../types"; + +export function useCompanyFormController({ + company, + csrfToken, + onCreated, + onSaved, +}: CompanyFormProps) { + const client = useQueryClient(); + const [savedCompanyId, setSavedCompanyId] = useState(company?.id); + const [partialSave, setPartialSave] = useState(false); + const [logoDraft, setLogoDraft] = useState(); + const form = useForm({ + resolver: zodResolver(CompanyRequestSchema), + defaultValues: { name: company?.name ?? "", active: company?.active ?? true }, + }); + const mutation = useMutation({ + mutationFn: async (values: CompanyRequestDTO) => { + setPartialSave(false); + const response = await saveCompany(savedCompanyId, values, csrfToken); + setSavedCompanyId(response.company.id); + client.setQueryData(companyKeys.detail(response.company.id), response); + if (logoDraft !== undefined) { + setPartialSave(true); + const logo = await saveCompanyLogo(response.company.id, logoDraft, csrfToken); + return { ...response, company: { ...response.company, logoVersion: logo.logoVersion } }; + } + return response; + }, + onError: async () => { + await client.invalidateQueries({ queryKey: companyKeys.list }); + }, + onSuccess: async (response) => { + setPartialSave(false); + setLogoDraft(undefined); + client.setQueryData(companyKeys.detail(response.company.id), response); + await client.invalidateQueries({ queryKey: companyKeys.list }); + if (company) { + form.reset({ name: response.company.name, active: response.company.active }); + } else { + setSavedCompanyId(undefined); + form.reset(); + onCreated?.(response.company.id); + } + onSaved?.(); + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { savedCompanyId, partialSave, logoDraft, setLogoDraft, form, mutation, submit }; +} diff --git a/modules/companies/src/web/companies/shared/index.ts b/modules/companies/src/web/companies/shared/index.ts index 973efe4..70d7ce4 100644 --- a/modules/companies/src/web/companies/shared/index.ts +++ b/modules/companies/src/web/companies/shared/index.ts @@ -1,4 +1,4 @@ -export { companyApi, companyKeys } from "./api"; +export { companyKeys } from "./queries"; export type { CompanyTab } from "./types"; export { CompanyAvatar, diff --git a/modules/companies/src/web/companies/shared/queries/company-query-keys.ts b/modules/companies/src/web/companies/shared/queries/company-query-keys.ts new file mode 100644 index 0000000..5b5605f --- /dev/null +++ b/modules/companies/src/web/companies/shared/queries/company-query-keys.ts @@ -0,0 +1,5 @@ +export const companyKeys = { + all: ["companies"], + list: ["companies", "list"], + detail: (id: string) => ["companies", "detail", id], +}; diff --git a/modules/companies/src/web/companies/shared/queries/index.ts b/modules/companies/src/web/companies/shared/queries/index.ts new file mode 100644 index 0000000..2e63116 --- /dev/null +++ b/modules/companies/src/web/companies/shared/queries/index.ts @@ -0,0 +1 @@ +export { companyKeys } from "./company-query-keys"; diff --git a/modules/companies/src/web/companies/shared/types/company-form-props.ts b/modules/companies/src/web/companies/shared/types/company-form-props.ts new file mode 100644 index 0000000..492912f --- /dev/null +++ b/modules/companies/src/web/companies/shared/types/company-form-props.ts @@ -0,0 +1,9 @@ +import type { CompanySummaryDTO } from "../../../../common"; + +export type CompanyFormProps = { + company?: CompanySummaryDTO; + csrfToken: string; + onCreated?: (id: string) => void; + onSaved?: () => void; + showTitle?: boolean; +}; diff --git a/modules/companies/src/web/companies/shared/types/index.ts b/modules/companies/src/web/companies/shared/types/index.ts index 398d5df..88da3a8 100644 --- a/modules/companies/src/web/companies/shared/types/index.ts +++ b/modules/companies/src/web/companies/shared/types/index.ts @@ -1 +1,2 @@ +export type { CompanyFormProps } from "./company-form-props"; export type { CompanyTab } from "./company-tab"; diff --git a/modules/companies/src/web/companies/shared/ui/company-avatar.tsx b/modules/companies/src/web/companies/shared/ui/company-avatar.tsx index d40fa3f..86800a2 100644 --- a/modules/companies/src/web/companies/shared/ui/company-avatar.tsx +++ b/modules/companies/src/web/companies/shared/ui/company-avatar.tsx @@ -5,7 +5,8 @@ import { useState } from "react"; import { InitialsAvatar } from "../../../shared/ui/initials-avatar"; import { companyLogoUrl } from "../../change-logo/api"; -import { companyApi, companyKeys } from "../api"; +import { listCompanies } from "../../list/api"; +import { companyKeys } from "../queries"; export type CompanyAvatarProps = Omit, "children"> & { companyId?: string | undefined; @@ -21,7 +22,7 @@ export function CompanyAvatar({ }: CompanyAvatarProps) { const companies = useQuery({ queryKey: companyKeys.list, - queryFn: companyApi.list, + queryFn: listCompanies, enabled: Boolean(companyId) && logoVersion === undefined, }); const version = diff --git a/modules/companies/src/web/companies/shared/ui/company-form.tsx b/modules/companies/src/web/companies/shared/ui/company-form.tsx index f6746c5..821aea5 100644 --- a/modules/companies/src/web/companies/shared/ui/company-form.tsx +++ b/modules/companies/src/web/companies/shared/ui/company-form.tsx @@ -1,78 +1,26 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyRequestDTO, CompanySummaryDTO } from "../../../../common"; -import { CompanyRequestSchema } from "../../../../common"; import { EntityHelp } from "../../../access/help"; -import { CompanyLogoField, saveCompanyLogo } from "../../change-logo"; -import { companyApi, companyKeys } from "../api"; +import { CompanyLogoField } from "../../change-logo"; +import { useCompanyFormController } from "../controllers"; +import type { CompanyFormProps } from "../types"; import { CompanyFeedback } from "./company-feedback"; -export function CompanyForm({ - company, - csrfToken, - onCreated, - onSaved, - showTitle = true, -}: { - company?: CompanySummaryDTO; - csrfToken: string; - onCreated?: (id: string) => void; - onSaved?: () => void; - showTitle?: boolean; -}) { +export function CompanyForm(props: CompanyFormProps) { + const { company, showTitle = true } = props; + const { savedCompanyId, partialSave, logoDraft, setLogoDraft, form, mutation, submit } = + useCompanyFormController(props); const { t } = useTranslation("companies"); const id = useId(); - const client = useQueryClient(); - const [savedCompanyId, setSavedCompanyId] = useState(company?.id); - const [partialSave, setPartialSave] = useState(false); - const [logoDraft, setLogoDraft] = useState(); - const form = useForm({ - resolver: zodResolver(CompanyRequestSchema), - defaultValues: { name: company?.name ?? "", active: company?.active ?? true }, - }); - const mutation = useMutation({ - mutationFn: async (values: CompanyRequestDTO) => { - setPartialSave(false); - const response = await companyApi.save(savedCompanyId, values, csrfToken); - setSavedCompanyId(response.company.id); - client.setQueryData(companyKeys.detail(response.company.id), response); - if (logoDraft !== undefined) { - setPartialSave(true); - const logo = await saveCompanyLogo(response.company.id, logoDraft, csrfToken); - return { ...response, company: { ...response.company, logoVersion: logo.logoVersion } }; - } - return response; - }, - onError: async () => { - await client.invalidateQueries({ queryKey: companyKeys.list }); - }, - onSuccess: async (response) => { - setPartialSave(false); - setLogoDraft(undefined); - client.setQueryData(companyKeys.detail(response.company.id), response); - await client.invalidateQueries({ queryKey: companyKeys.list }); - if (company) { - form.reset({ name: response.company.name, active: response.company.active }); - } else { - setSavedCompanyId(undefined); - form.reset(); - onCreated?.(response.company.id); - } - onSaved?.(); - }, - }); return ( - mutation.mutate(values))}> + {showTitle && (

{t(company ? "editCompany" : "newCompany")}

)} diff --git a/modules/companies/src/web/dashboard/create/controllers/index.ts b/modules/companies/src/web/dashboard/create/controllers/index.ts new file mode 100644 index 0000000..cffd5cb --- /dev/null +++ b/modules/companies/src/web/dashboard/create/controllers/index.ts @@ -0,0 +1 @@ +export { useDashboardQuickCreateController } from "./use-dashboard-quick-create-controller"; diff --git a/modules/companies/src/web/dashboard/create/controllers/use-dashboard-quick-create-controller.ts b/modules/companies/src/web/dashboard/create/controllers/use-dashboard-quick-create-controller.ts new file mode 100644 index 0000000..8f88f4a --- /dev/null +++ b/modules/companies/src/web/dashboard/create/controllers/use-dashboard-quick-create-controller.ts @@ -0,0 +1,43 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { listCompanies } from "../../../companies/list/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import { interventionApi, interventionKeys } from "../../../interventions/shared/api"; +import { ticketApi, ticketKeys } from "../../../tickets/shared/api"; +import type { DashboardQuickCreateProps } from "../types"; + +export function useDashboardQuickCreateController({ + kind, + companyId, + onClose, +}: DashboardQuickCreateProps) { + const client = useQueryClient(); + const [selectedCompany, setSelectedCompany] = useState(companyId ?? ""); + const [addingCompany, setAddingCompany] = useState(false); + const companies = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies }); + const tickets = useQuery({ + queryKey: ticketKeys.list(companyId), + queryFn: () => ticketApi.list(companyId), + enabled: kind === "ticket", + }); + const interventions = useQuery({ + queryKey: interventionKeys.list(companyId), + queryFn: () => interventionApi.list(companyId), + enabled: kind === "intervention", + }); + const saved = () => { + client.invalidateQueries({ queryKey: ["dashboard"] }).catch(() => undefined); + onClose(); + }; + return { + selectedCompany, + setSelectedCompany, + addingCompany, + setAddingCompany, + companies, + tickets, + interventions, + saved, + }; +} diff --git a/modules/companies/src/web/dashboard/create/types/dashboard-quick-create-props.ts b/modules/companies/src/web/dashboard/create/types/dashboard-quick-create-props.ts new file mode 100644 index 0000000..6f41bb1 --- /dev/null +++ b/modules/companies/src/web/dashboard/create/types/dashboard-quick-create-props.ts @@ -0,0 +1,9 @@ +import type { DashboardNavigation } from "../../shared/helpers"; + +export type DashboardQuickCreateProps = { + kind: "company" | "ticket" | "intervention" | "asset" | "resource"; + companyId?: string | undefined; + csrfToken: string; + onClose: () => void; + onNavigate: (destination: DashboardNavigation) => void; +}; diff --git a/modules/companies/src/web/dashboard/create/types/index.ts b/modules/companies/src/web/dashboard/create/types/index.ts new file mode 100644 index 0000000..4f6515e --- /dev/null +++ b/modules/companies/src/web/dashboard/create/types/index.ts @@ -0,0 +1 @@ +export type { DashboardQuickCreateProps } from "./dashboard-quick-create-props"; diff --git a/modules/companies/src/web/dashboard/create/ui/dashboard-quick-create.tsx b/modules/companies/src/web/dashboard/create/ui/dashboard-quick-create.tsx index c0cb9ff..cae7887 100644 --- a/modules/companies/src/web/dashboard/create/ui/dashboard-quick-create.tsx +++ b/modules/companies/src/web/dashboard/create/ui/dashboard-quick-create.tsx @@ -1,48 +1,28 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { CompanyEditorDialog, CompanyForm, companyApi, companyKeys } from "../../../companies"; -import { InterventionForm, interventionApi, interventionKeys } from "../../../interventions"; -import { TicketForm, ticketApi, ticketKeys } from "../../../tickets"; -import type { DashboardNavigation } from "../../shared"; +import { CompanyEditorDialog, CompanyForm } from "../../../companies"; +import { InterventionForm } from "../../../interventions"; +import { TicketForm } from "../../../tickets"; import { BlockState } from "../../shared"; +import { useDashboardQuickCreateController } from "../controllers"; +import type { DashboardQuickCreateProps } from "../types"; -export function DashboardQuickCreate({ - kind, - companyId, - csrfToken, - onClose, - onNavigate, -}: { - kind: "company" | "ticket" | "intervention" | "asset" | "resource"; - companyId?: string | undefined; - csrfToken: string; - onClose: () => void; - onNavigate: (destination: DashboardNavigation) => void; -}) { +export function DashboardQuickCreate(props: DashboardQuickCreateProps) { + const { kind, companyId, csrfToken, onClose, onNavigate } = props; + const { + selectedCompany, + setSelectedCompany, + addingCompany, + setAddingCompany, + companies, + tickets, + interventions, + saved, + } = useDashboardQuickCreateController(props); const { t } = useTranslation("companies"); - const client = useQueryClient(); - const [selectedCompany, setSelectedCompany] = useState(companyId ?? ""); - const [addingCompany, setAddingCompany] = useState(false); - const companies = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); - const tickets = useQuery({ - queryKey: ticketKeys.list(companyId), - queryFn: () => ticketApi.list(companyId), - enabled: kind === "ticket", - }); - const interventions = useQuery({ - queryKey: interventionKeys.list(companyId), - queryFn: () => interventionApi.list(companyId), - enabled: kind === "intervention", - }); - const saved = () => { - client.invalidateQueries({ queryKey: ["dashboard"] }).catch(() => undefined); - onClose(); - }; return ( <> { + if (details.work) { + return dashboardApi.work(signal, companyId, page); + } + if (details.activity) { + return dashboardApi.activity(signal, companyId); + } + return dashboardApi.details(signal, { + companyId, + page, + kind: details.kind, + issue: details.issue, + }); + }, + }); + const companies = useQuery({ + queryKey: dashboardKeys.block("companies", companyId), + enabled: Boolean(details.companies), + queryFn: ({ signal }) => dashboardApi.companies(signal, companyId), + }); + const total = query.data?.total ?? 0; + return { page, setPage, query, companies, total }; +} diff --git a/modules/companies/src/web/dashboard/detail/helpers/dashboard-details-dialog-values.ts b/modules/companies/src/web/dashboard/detail/helpers/dashboard-details-dialog-values.ts new file mode 100644 index 0000000..49c0bd1 --- /dev/null +++ b/modules/companies/src/web/dashboard/detail/helpers/dashboard-details-dialog-values.ts @@ -0,0 +1,25 @@ +import type { DashboardIssue } from "../../../../common/dashboard"; + +export function detailsTitle(details: { + issue?: DashboardIssue; + kind?: "asset" | "resource"; + work?: boolean; + activity?: boolean; +}) { + if (details.issue) { + return `dashboard.issues.${details.issue}`; + } + if (details.kind === "asset") { + return "dashboard.registeredAssets"; + } + if (details.kind === "resource") { + return "dashboard.registeredResources"; + } + if (details.work) { + return "dashboard.pendingWork"; + } + if (details.activity) { + return "dashboard.recentActivity"; + } + return "dashboard.companySummary"; +} diff --git a/modules/companies/src/web/dashboard/detail/helpers/index.ts b/modules/companies/src/web/dashboard/detail/helpers/index.ts new file mode 100644 index 0000000..3205b74 --- /dev/null +++ b/modules/companies/src/web/dashboard/detail/helpers/index.ts @@ -0,0 +1 @@ +export { detailsTitle } from "./dashboard-details-dialog-values"; diff --git a/modules/companies/src/web/dashboard/detail/types/dashboard-details-dialog-props.ts b/modules/companies/src/web/dashboard/detail/types/dashboard-details-dialog-props.ts new file mode 100644 index 0000000..0f16f29 --- /dev/null +++ b/modules/companies/src/web/dashboard/detail/types/dashboard-details-dialog-props.ts @@ -0,0 +1,15 @@ +import type { DashboardIssue } from "../../../../common/dashboard"; +import type { DashboardNavigation } from "../../shared/helpers"; + +export type DashboardDetailsDialogProps = { + companyId?: string | undefined; + details: { + kind?: "asset" | "resource"; + issue?: DashboardIssue; + work?: boolean; + activity?: boolean; + companies?: boolean; + }; + onClose: () => void; + onNavigate: (destination: DashboardNavigation) => void; +}; diff --git a/modules/companies/src/web/dashboard/detail/types/index.ts b/modules/companies/src/web/dashboard/detail/types/index.ts new file mode 100644 index 0000000..0101d0b --- /dev/null +++ b/modules/companies/src/web/dashboard/detail/types/index.ts @@ -0,0 +1 @@ +export type { DashboardDetailsDialogProps } from "./dashboard-details-dialog-props"; diff --git a/modules/companies/src/web/dashboard/detail/ui/dashboard-details-dialog.tsx b/modules/companies/src/web/dashboard/detail/ui/dashboard-details-dialog.tsx index 28d73bc..d38f5b3 100644 --- a/modules/companies/src/web/dashboard/detail/ui/dashboard-details-dialog.tsx +++ b/modules/companies/src/web/dashboard/detail/ui/dashboard-details-dialog.tsx @@ -2,68 +2,21 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { EntityTable } from "@repo/shadcn-ui/components/entity-list"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import { entityListLabels } from "@repo/shadcn-ui/lib/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { DashboardIssue } from "../../../../common/dashboard"; import { CompanyIdentity } from "../../../companies"; -import type { DashboardNavigation } from "../../shared"; -import { - BlockState, - DashboardNavigationButton, - DashboardTable, - dashboardApi, - dashboardKeys, - entryDestination, -} from "../../shared"; +import { BlockState, DashboardNavigationButton, DashboardTable } from "../../shared"; +import { entryDestination } from "../../shared/helpers"; +import { useDashboardDetailsDialogController } from "../controllers"; +import { detailsTitle } from "../helpers"; +import type { DashboardDetailsDialogProps } from "../types"; -export function DashboardDetailsDialog({ - companyId, - details, - onClose, - onNavigate, -}: { - companyId?: string | undefined; - details: { - kind?: "asset" | "resource"; - issue?: DashboardIssue; - work?: boolean; - activity?: boolean; - companies?: boolean; - }; - onClose: () => void; - onNavigate: (destination: DashboardNavigation) => void; -}) { +export function DashboardDetailsDialog(props: DashboardDetailsDialogProps) { + const { details, onClose, onNavigate } = props; + const { page, setPage, query, companies, total } = useDashboardDetailsDialogController(props); const { t, i18n } = useTranslation("companies"); - const [page, setPage] = useState(1); - const block = details.work ? "work" : details.activity ? "activity" : "details"; - const query = useQuery({ - queryKey: dashboardKeys.block(block, companyId, page, details.issue ?? details.kind), - enabled: !details.companies, - queryFn: ({ signal }) => { - if (details.work) { - return dashboardApi.work(signal, companyId, page); - } - if (details.activity) { - return dashboardApi.activity(signal, companyId); - } - return dashboardApi.details(signal, { - companyId, - page, - kind: details.kind, - issue: details.issue, - }); - }, - }); - const companies = useQuery({ - queryKey: dashboardKeys.block("companies", companyId), - enabled: Boolean(details.companies), - queryFn: ({ signal }) => dashboardApi.companies(signal, companyId), - }); const title = t(detailsTitle(details)); const labels = entityListLabels(i18n.language); - const total = query.data?.total ?? 0; const range = labels.range( total ? (page - 1) * 10 + 1 : 0, Math.min(page * 10, total), @@ -226,27 +179,3 @@ export function DashboardDetailsDialog({ ); } - -function detailsTitle(details: { - issue?: DashboardIssue; - kind?: "asset" | "resource"; - work?: boolean; - activity?: boolean; -}) { - if (details.issue) { - return `dashboard.issues.${details.issue}`; - } - if (details.kind === "asset") { - return "dashboard.registeredAssets"; - } - if (details.kind === "resource") { - return "dashboard.registeredResources"; - } - if (details.work) { - return "dashboard.pendingWork"; - } - if (details.activity) { - return "dashboard.recentActivity"; - } - return "dashboard.companySummary"; -} diff --git a/modules/companies/src/web/dashboard/index.ts b/modules/companies/src/web/dashboard/index.ts index 913899e..59f4dc8 100644 --- a/modules/companies/src/web/dashboard/index.ts +++ b/modules/companies/src/web/dashboard/index.ts @@ -1,9 +1,5 @@ -export type { DashboardNavigation } from "./shared"; -export { - DashboardNavigationButton, - DashboardStateBadge, - DashboardTable, - dashboardKeys, - entryDestination, -} from "./shared"; +export { DashboardNavigationButton, DashboardStateBadge, DashboardTable } from "./shared"; +export { dashboardKeys } from "./shared/api"; +export type { DashboardNavigation } from "./shared/helpers"; +export { entryDestination } from "./shared/helpers"; export { DashboardPage } from "./view"; diff --git a/modules/companies/src/web/dashboard/shared/api/dashboard-api.ts b/modules/companies/src/web/dashboard/shared/api/dashboard-api.ts index 22070b5..dab9c89 100644 --- a/modules/companies/src/web/dashboard/shared/api/dashboard-api.ts +++ b/modules/companies/src/web/dashboard/shared/api/dashboard-api.ts @@ -15,10 +15,13 @@ export const dashboardKeys = { kind, ], }; + async function request( block: string, signal: AbortSignal, - query: Omit & { page?: number } = {} + query: Omit & { + page?: number; + } = {} ) { const parameters = new URLSearchParams(); for (const [key, value] of Object.entries(query)) { @@ -36,6 +39,7 @@ async function request( const body: unknown = await response.json(); return body; } + export const dashboardApi = { summary: async (signal: AbortSignal, companyId?: string | undefined) => DashboardSummarySchema.parse(await request("summary", signal, { companyId })), @@ -50,51 +54,3 @@ export const dashboardApi = { details: async (signal: AbortSignal, query: DashboardDetailsQuery) => DashboardEntriesSchema.parse(await request("details", signal, query)), }; -export type DashboardNavigation = - | { - area: "directory"; - companyId?: string | undefined; - activeOnly?: boolean; - create?: boolean | undefined; - } - | { - area: "tickets" | "interventions"; - companyId?: string | undefined; - id?: string | undefined; - pendingOnly?: boolean | undefined; - create?: boolean | undefined; - } - | { - area: "company"; - id: string; - section: "inventory" | "resources" | "profiles" | "people"; - entityId?: string | undefined; - create?: boolean | undefined; - }; - -export function entryDestination(item: { - kind: string; - companyId: string; - id: string; -}): DashboardNavigation { - if (item.kind === "ticket" || item.kind === "intervention") { - return { - area: item.kind === "ticket" ? "tickets" : "interventions", - companyId: item.companyId, - id: item.id, - }; - } - return { - area: "company", - id: item.companyId, - section: - item.kind === "asset" - ? "inventory" - : item.kind === "resource" - ? "resources" - : item.kind === "profile" - ? "profiles" - : "people", - entityId: item.id, - }; -} diff --git a/modules/companies/src/web/dashboard/shared/api/index.ts b/modules/companies/src/web/dashboard/shared/api/index.ts index 51773aa..77fec6a 100644 --- a/modules/companies/src/web/dashboard/shared/api/index.ts +++ b/modules/companies/src/web/dashboard/shared/api/index.ts @@ -1,2 +1 @@ -export type { DashboardNavigation } from "./dashboard-api"; -export { dashboardApi, dashboardKeys, entryDestination } from "./dashboard-api"; +export { dashboardApi, dashboardKeys } from "./dashboard-api"; diff --git a/modules/companies/src/web/dashboard/shared/helpers/dashboard-navigation.ts b/modules/companies/src/web/dashboard/shared/helpers/dashboard-navigation.ts new file mode 100644 index 0000000..6838b9a --- /dev/null +++ b/modules/companies/src/web/dashboard/shared/helpers/dashboard-navigation.ts @@ -0,0 +1,48 @@ +export type DashboardNavigation = + | { + area: "directory"; + companyId?: string | undefined; + activeOnly?: boolean; + create?: boolean | undefined; + } + | { + area: "tickets" | "interventions"; + companyId?: string | undefined; + id?: string | undefined; + pendingOnly?: boolean | undefined; + create?: boolean | undefined; + } + | { + area: "company"; + id: string; + section: "inventory" | "resources" | "profiles" | "people"; + entityId?: string | undefined; + create?: boolean | undefined; + }; + +export function entryDestination(item: { + kind: string; + companyId: string; + id: string; +}): DashboardNavigation { + if (item.kind === "ticket" || item.kind === "intervention") { + return { + area: item.kind === "ticket" ? "tickets" : "interventions", + companyId: item.companyId, + id: item.id, + }; + } + return { + area: "company", + id: item.companyId, + section: + item.kind === "asset" + ? "inventory" + : item.kind === "resource" + ? "resources" + : item.kind === "profile" + ? "profiles" + : "people", + entityId: item.id, + }; +} diff --git a/modules/companies/src/web/dashboard/shared/helpers/index.ts b/modules/companies/src/web/dashboard/shared/helpers/index.ts index aa9005f..729adf2 100644 --- a/modules/companies/src/web/dashboard/shared/helpers/index.ts +++ b/modules/companies/src/web/dashboard/shared/helpers/index.ts @@ -1 +1,3 @@ export { KIND_ICONS } from "./dashboard-kind-icons"; +export type { DashboardNavigation } from "./dashboard-navigation"; +export { entryDestination } from "./dashboard-navigation"; diff --git a/modules/companies/src/web/dashboard/shared/index.ts b/modules/companies/src/web/dashboard/shared/index.ts index d66467a..7d51629 100644 --- a/modules/companies/src/web/dashboard/shared/index.ts +++ b/modules/companies/src/web/dashboard/shared/index.ts @@ -1,6 +1,6 @@ -export type { DashboardNavigation } from "./api"; -export { dashboardApi, dashboardKeys, entryDestination } from "./api"; -export { KIND_ICONS } from "./helpers"; +export { dashboardApi, dashboardKeys } from "./api"; +export type { DashboardNavigation } from "./helpers"; +export { KIND_ICONS, entryDestination } from "./helpers"; export { BlockState, DashboardNavigationButton, diff --git a/modules/companies/src/web/dashboard/view/controllers/index.ts b/modules/companies/src/web/dashboard/view/controllers/index.ts new file mode 100644 index 0000000..9ed7a7f --- /dev/null +++ b/modules/companies/src/web/dashboard/view/controllers/index.ts @@ -0,0 +1 @@ +export { useDashboardPageController } from "./use-dashboard-page-controller"; diff --git a/modules/companies/src/web/dashboard/view/controllers/use-dashboard-page-controller.ts b/modules/companies/src/web/dashboard/view/controllers/use-dashboard-page-controller.ts new file mode 100644 index 0000000..283fb1e --- /dev/null +++ b/modules/companies/src/web/dashboard/view/controllers/use-dashboard-page-controller.ts @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ComponentProps } from "react"; +import { useState } from "react"; + +import { listCompanies } from "../../../companies/list/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import type { DashboardQuickCreate } from "../../create"; +import type { DashboardDetailsDialog } from "../../detail"; +import { dashboardApi, dashboardKeys } from "../../shared/api"; + +export function useDashboardPageController() { + const [companyId, setCompanyId] = useState(""); + const scope = companyId || undefined; + const [details, setDetails] = + useState["details"]>(); + const [quick, setQuick] = useState["kind"]>(); + const companies = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies }); + const summary = useQuery({ + queryKey: dashboardKeys.block("summary", scope), + queryFn: ({ signal }) => dashboardApi.summary(signal, scope), + staleTime: 0, + }); + return { + companyId, + setCompanyId, + scope, + details, + setDetails, + quick, + setQuick, + companies, + summary, + }; +} diff --git a/modules/companies/src/web/dashboard/view/types/dashboard-page-props.ts b/modules/companies/src/web/dashboard/view/types/dashboard-page-props.ts new file mode 100644 index 0000000..a049055 --- /dev/null +++ b/modules/companies/src/web/dashboard/view/types/dashboard-page-props.ts @@ -0,0 +1,6 @@ +import type { DashboardNavigation } from "../../shared/helpers"; + +export type DashboardPageProps = { + onNavigate: (destination: DashboardNavigation) => void; + csrfToken: string; +}; diff --git a/modules/companies/src/web/dashboard/view/types/index.ts b/modules/companies/src/web/dashboard/view/types/index.ts new file mode 100644 index 0000000..d0d5085 --- /dev/null +++ b/modules/companies/src/web/dashboard/view/types/index.ts @@ -0,0 +1 @@ +export type { DashboardPageProps } from "./dashboard-page-props"; diff --git a/modules/companies/src/web/dashboard/view/ui/dashboard-activity-panel.tsx b/modules/companies/src/web/dashboard/view/ui/dashboard-activity-panel.tsx index 3f59a71..410c3a0 100644 --- a/modules/companies/src/web/dashboard/view/ui/dashboard-activity-panel.tsx +++ b/modules/companies/src/web/dashboard/view/ui/dashboard-activity-panel.tsx @@ -3,16 +3,10 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { CompanyIdentity } from "../../../companies"; -import type { DashboardNavigation } from "../../shared"; -import { - BlockState, - DashboardNavigationButton, - DashboardPanel, - KIND_ICONS, - dashboardApi, - dashboardKeys, - entryDestination, -} from "../../shared"; +import { BlockState, DashboardNavigationButton, DashboardPanel } from "../../shared"; +import { dashboardApi, dashboardKeys } from "../../shared/api"; +import type { DashboardNavigation } from "../../shared/helpers"; +import { KIND_ICONS, entryDestination } from "../../shared/helpers"; export function DashboardActivityPanel({ companyId, onDetails, diff --git a/modules/companies/src/web/dashboard/view/ui/dashboard-attention-panel.tsx b/modules/companies/src/web/dashboard/view/ui/dashboard-attention-panel.tsx index b53e7fc..28fd268 100644 --- a/modules/companies/src/web/dashboard/view/ui/dashboard-attention-panel.tsx +++ b/modules/companies/src/web/dashboard/view/ui/dashboard-attention-panel.tsx @@ -10,7 +10,8 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { DashboardIssue } from "../../../../common/dashboard"; -import { BlockState, DashboardPanel, dashboardApi, dashboardKeys } from "../../shared"; +import { BlockState, DashboardPanel } from "../../shared"; +import { dashboardApi, dashboardKeys } from "../../shared/api"; const ISSUE_ICONS = { certificate: FileKeyIcon, diff --git a/modules/companies/src/web/dashboard/view/ui/dashboard-companies-panel.tsx b/modules/companies/src/web/dashboard/view/ui/dashboard-companies-panel.tsx index cdbb592..ddce767 100644 --- a/modules/companies/src/web/dashboard/view/ui/dashboard-companies-panel.tsx +++ b/modules/companies/src/web/dashboard/view/ui/dashboard-companies-panel.tsx @@ -3,15 +3,14 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { CompanyIdentity } from "../../../companies"; -import type { DashboardNavigation } from "../../shared"; import { BlockState, DashboardNavigationButton, DashboardPanel, DashboardTable, - dashboardApi, - dashboardKeys, } from "../../shared"; +import { dashboardApi, dashboardKeys } from "../../shared/api"; +import type { DashboardNavigation } from "../../shared/helpers"; export function DashboardCompaniesPanel({ companyId, onDetails, diff --git a/modules/companies/src/web/dashboard/view/ui/dashboard-page.tsx b/modules/companies/src/web/dashboard/view/ui/dashboard-page.tsx index 4055525..fe83799 100644 --- a/modules/companies/src/web/dashboard/view/ui/dashboard-page.tsx +++ b/modules/companies/src/web/dashboard/view/ui/dashboard-page.tsx @@ -1,15 +1,14 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Building2Icon } from "@repo/shadcn-ui/lib/icons"; -import { useQuery } from "@tanstack/react-query"; -import type { ComponentProps } from "react"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { CompanySelect, companyApi, companyKeys } from "../../../companies"; +import { CompanySelect } from "../../../companies"; import { DashboardQuickCreate } from "../../create"; import { DashboardDetailsDialog } from "../../detail"; -import type { DashboardNavigation } from "../../shared"; -import { DashboardPanel, KIND_ICONS, dashboardApi, dashboardKeys } from "../../shared"; +import { DashboardPanel } from "../../shared"; +import { KIND_ICONS } from "../../shared/helpers"; +import { useDashboardPageController } from "../controllers"; +import type { DashboardPageProps } from "../types"; import { DashboardActivityPanel } from "./dashboard-activity-panel"; import { DashboardAttentionPanel } from "./dashboard-attention-panel"; @@ -17,26 +16,20 @@ import { DashboardCompaniesPanel } from "./dashboard-companies-panel"; import { DashboardStatistics } from "./dashboard-statistics"; import { DashboardWorkPanel } from "./dashboard-work-panel"; -export function DashboardPage({ - onNavigate, - csrfToken, -}: { - onNavigate: (destination: DashboardNavigation) => void; - csrfToken: string; -}) { +export function DashboardPage(props: DashboardPageProps) { + const { onNavigate, csrfToken } = props; + const { + companyId, + setCompanyId, + scope, + details, + setDetails, + quick, + setQuick, + companies, + summary, + } = useDashboardPageController(); const { t } = useTranslation("companies"); - const [companyId, setCompanyId] = useState(""); - const scope = companyId || undefined; - const [details, setDetails] = - useState["details"]>(); - const [quick, setQuick] = useState["kind"]>(); - const companies = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); - const summary = useQuery({ - queryKey: dashboardKeys.block("summary", scope), - queryFn: ({ signal }) => dashboardApi.summary(signal, scope), - staleTime: 0, - }); - return (
diff --git a/modules/companies/src/web/dashboard/view/ui/dashboard-statistics.tsx b/modules/companies/src/web/dashboard/view/ui/dashboard-statistics.tsx index e1272dd..ef6394a 100644 --- a/modules/companies/src/web/dashboard/view/ui/dashboard-statistics.tsx +++ b/modules/companies/src/web/dashboard/view/ui/dashboard-statistics.tsx @@ -10,7 +10,7 @@ import type { UseQueryResult } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { DashboardSummaryDTO } from "../../../../common/dashboard"; -import type { DashboardNavigation } from "../../shared"; +import type { DashboardNavigation } from "../../shared/helpers"; export function DashboardStatistics({ scope, summary, diff --git a/modules/companies/src/web/dashboard/view/ui/dashboard-work-panel.tsx b/modules/companies/src/web/dashboard/view/ui/dashboard-work-panel.tsx index 62215aa..b9ae88d 100644 --- a/modules/companies/src/web/dashboard/view/ui/dashboard-work-panel.tsx +++ b/modules/companies/src/web/dashboard/view/ui/dashboard-work-panel.tsx @@ -3,18 +3,16 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { CompanyIdentity } from "../../../companies"; -import type { DashboardNavigation } from "../../shared"; import { BlockState, DashboardNavigationButton, DashboardPanel, DashboardStateBadge, DashboardTable, - KIND_ICONS, - dashboardApi, - dashboardKeys, - entryDestination, } from "../../shared"; +import { dashboardApi, dashboardKeys } from "../../shared/api"; +import type { DashboardNavigation } from "../../shared/helpers"; +import { KIND_ICONS, entryDestination } from "../../shared/helpers"; export function DashboardWorkPanel({ companyId, onDetails, diff --git a/modules/companies/src/web/departments/shared/api/index.ts b/modules/companies/src/web/departments/shared/api/index.ts new file mode 100644 index 0000000..d2f1b3e --- /dev/null +++ b/modules/companies/src/web/departments/shared/api/index.ts @@ -0,0 +1 @@ +export { saveDepartment } from "./save-department.api"; diff --git a/modules/companies/src/web/departments/shared/api/save-department.api.ts b/modules/companies/src/web/departments/shared/api/save-department.api.ts new file mode 100644 index 0000000..ace1c7e --- /dev/null +++ b/modules/companies/src/web/departments/shared/api/save-department.api.ts @@ -0,0 +1,15 @@ +import { CompanyResponseSchema } from "../../../../common"; +import { companyRequest } from "../../../companies/shared/api/company-request"; + +export const saveDepartment = ( + companyId: string, + id: string | undefined, + body: unknown, + token: string +) => + companyRequest( + `/${companyId}/departments${id ? `/${id}/update` : ""}`, + CompanyResponseSchema, + body, + token + ); diff --git a/modules/companies/src/web/departments/shared/controllers/index.ts b/modules/companies/src/web/departments/shared/controllers/index.ts new file mode 100644 index 0000000..da2b0df --- /dev/null +++ b/modules/companies/src/web/departments/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useDepartmentFormController } from "./use-department-form-controller"; diff --git a/modules/companies/src/web/departments/shared/controllers/use-department-form-controller.ts b/modules/companies/src/web/departments/shared/controllers/use-department-form-controller.ts new file mode 100644 index 0000000..675995e --- /dev/null +++ b/modules/companies/src/web/departments/shared/controllers/use-department-form-controller.ts @@ -0,0 +1,45 @@ +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 { DepartmentRequestDTO } from "../../../../common"; +import { DepartmentRequestSchema } from "../../../../common"; +import { companyKeys } from "../../../companies/shared/queries"; +import { saveDepartment } from "../api"; +import type { DepartmentFormProps } from "../types"; + +export function useDepartmentFormController({ + companyId, + department, + csrfToken, + onSaved, +}: DepartmentFormProps) { + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(DepartmentRequestSchema), + defaultValues: { name: department?.name ?? "", active: department?.active ?? true }, + }); + const mutation = useMutation({ + mutationFn: (values: DepartmentRequestDTO) => + saveDepartment(companyId, department?.id, values, csrfToken), + onSuccess: (response) => { + client.setQueryData(companyKeys.detail(companyId), response); + const saved = department + ? response.company.departments.find((item) => item.id === department.id) + : response.company.departments.at(-1); + if (saved && mounted.current) { + onSaved(saved.id); + } + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/companies/src/web/departments/shared/types/department-form-props.ts b/modules/companies/src/web/departments/shared/types/department-form-props.ts new file mode 100644 index 0000000..b4d711c --- /dev/null +++ b/modules/companies/src/web/departments/shared/types/department-form-props.ts @@ -0,0 +1,9 @@ +import type { DepartmentDTO } from "../../../../common"; + +export type DepartmentFormProps = { + companyId: string; + department?: DepartmentDTO | undefined; + csrfToken: string; + onSaved: (id: string) => void; + requireActive?: boolean; +}; diff --git a/modules/companies/src/web/departments/shared/types/index.ts b/modules/companies/src/web/departments/shared/types/index.ts new file mode 100644 index 0000000..7e0b6bb --- /dev/null +++ b/modules/companies/src/web/departments/shared/types/index.ts @@ -0,0 +1 @@ +export type { DepartmentFormProps } from "./department-form-props"; diff --git a/modules/companies/src/web/departments/shared/ui/department-form.tsx b/modules/companies/src/web/departments/shared/ui/department-form.tsx index 16eb8f3..da910b1 100644 --- a/modules/companies/src/web/departments/shared/ui/department-form.tsx +++ b/modules/companies/src/web/departments/shared/ui/department-form.tsx @@ -1,61 +1,23 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { DepartmentDTO, DepartmentRequestDTO } from "../../../../common"; -import { DepartmentRequestSchema } from "../../../../common"; import { EntityHelp } from "../../../access/help"; -import { CompanyFeedback, companyApi, companyKeys } from "../../../companies/shared"; +import { CompanyFeedback } from "../../../companies/shared"; +import { useDepartmentFormController } from "../controllers"; +import type { DepartmentFormProps } from "../types"; -export function DepartmentForm({ - companyId, - department, - csrfToken, - onSaved, - requireActive = false, -}: { - companyId: string; - department?: DepartmentDTO | undefined; - csrfToken: string; - onSaved: (id: string) => void; - requireActive?: boolean; -}) { +export function DepartmentForm(props: DepartmentFormProps) { + const { department, requireActive = false } = props; + const { form, mutation, submit } = useDepartmentFormController(props); const { t } = useTranslation("companies"); const id = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const form = useForm({ - resolver: zodResolver(DepartmentRequestSchema), - defaultValues: { name: department?.name ?? "", active: department?.active ?? true }, - }); - const mutation = useMutation({ - mutationFn: (values: DepartmentRequestDTO) => - companyApi.department(companyId, department?.id, values, csrfToken), - onSuccess: (response) => { - client.setQueryData(companyKeys.detail(companyId), response); - const saved = department - ? response.company.departments.find((item) => item.id === department.id) - : response.company.departments.at(-1); - if (saved && mounted.current) { - onSaved(saved.id); - } - }, - }); return ( - mutation.mutate(values))}> +
diff --git a/modules/companies/src/web/folder-mappings/index.ts b/modules/companies/src/web/folder-mappings/index.ts index e1a82a5..5913d3e 100644 --- a/modules/companies/src/web/folder-mappings/index.ts +++ b/modules/companies/src/web/folder-mappings/index.ts @@ -1,2 +1,2 @@ export { FolderMappingsPage } from "./list"; -export { folderApi, folderKeys } from "./shared"; +export { folderApi, folderKeys } from "./shared/api"; diff --git a/modules/companies/src/web/folder-mappings/list/controllers/index.ts b/modules/companies/src/web/folder-mappings/list/controllers/index.ts new file mode 100644 index 0000000..27a4307 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/list/controllers/index.ts @@ -0,0 +1 @@ +export { useFolderMappingsPageController } from "./use-folder-mappings-page-controller"; diff --git a/modules/companies/src/web/folder-mappings/list/controllers/use-folder-mappings-page-controller.ts b/modules/companies/src/web/folder-mappings/list/controllers/use-folder-mappings-page-controller.ts new file mode 100644 index 0000000..70296cf --- /dev/null +++ b/modules/companies/src/web/folder-mappings/list/controllers/use-folder-mappings-page-controller.ts @@ -0,0 +1,61 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { getAccess } from "../../../access/list/api"; +import { accessKeys } from "../../../access/shared/queries"; +import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; +import { folderApi, folderKeys } from "../../shared/api"; +import { folderDetails } from "../helpers"; +import type { FolderMappingsPageProps } from "../types"; + +export function useFolderMappingsPageController({ + company, + initialProfileId, +}: FolderMappingsPageProps) { + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: folderKeys.company(company.id), + queryFn: () => folderApi.get(company.id), + }); + const access = useQuery({ + queryKey: accessKeys.scope(company.id), + queryFn: () => getAccess(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const items = access.data?.items ?? []; + const mappings = query.data?.mappings ?? []; + const profile = (id: string) => + profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id; + const folder = (id: string) => folderDetails(items, id); + const filtered = mappings.filter( + (item) => + (!initialProfileId || item.values.profileId === initialProfileId) && + [item.values.path, profile(item.values.profileId), folder(item.values.needId).name].some( + (text) => text.toLowerCase().includes(search.trim().toLowerCase()) + ) + ); + const edited = mappings.find((item) => item.id === editor); + const viewed = mappings.find((item) => item.id === view); + const ready = [query.data, access.data, profiles.data].every(Boolean); + return { + editor, + setEditor, + setView, + search, + setSearch, + query, + access, + profiles, + profile, + folder, + filtered, + edited, + viewed, + ready, + }; +} diff --git a/modules/companies/src/web/folder-mappings/list/helpers/folder-mappings-filtering.ts b/modules/companies/src/web/folder-mappings/list/helpers/folder-mappings-filtering.ts new file mode 100644 index 0000000..f1c4d57 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/list/helpers/folder-mappings-filtering.ts @@ -0,0 +1,18 @@ +import type { AccessItemDTO } from "../../../../common"; + +export function folderDetails(items: readonly AccessItemDTO[], id: string) { + const need = items.find((item) => item.id === id)?.values; + if (need?.kind !== "need") { + return { name: id, permissions: "" }; + } + const resource = items.find((item) => item.id === need.resourceId)?.values; + return { + name: resource?.kind === "resource" ? resource.name : need.resourceId, + permissions: need.permissionIds + .map((permission) => { + const item = items.find((item) => item.id === permission)?.values; + return item?.kind === "permission" ? item.name : permission; + }) + .join(" · "), + }; +} diff --git a/modules/companies/src/web/folder-mappings/list/helpers/index.ts b/modules/companies/src/web/folder-mappings/list/helpers/index.ts new file mode 100644 index 0000000..4122f38 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/list/helpers/index.ts @@ -0,0 +1 @@ +export { folderDetails } from "./folder-mappings-filtering"; diff --git a/modules/companies/src/web/folder-mappings/list/types/folder-mappings-page-props.ts b/modules/companies/src/web/folder-mappings/list/types/folder-mappings-page-props.ts new file mode 100644 index 0000000..d5dca2d --- /dev/null +++ b/modules/companies/src/web/folder-mappings/list/types/folder-mappings-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type FolderMappingsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/folder-mappings/list/types/index.ts b/modules/companies/src/web/folder-mappings/list/types/index.ts new file mode 100644 index 0000000..18cdfdd --- /dev/null +++ b/modules/companies/src/web/folder-mappings/list/types/index.ts @@ -0,0 +1 @@ +export type { FolderMappingsPageProps } from "./folder-mappings-page-props"; diff --git a/modules/companies/src/web/folder-mappings/list/ui/folder-mappings-page.tsx b/modules/companies/src/web/folder-mappings/list/ui/folder-mappings-page.tsx index 1771f2e..be6c691 100644 --- a/modules/companies/src/web/folder-mappings/list/ui/folder-mappings-page.tsx +++ b/modules/companies/src/web/folder-mappings/list/ui/folder-mappings-page.tsx @@ -6,56 +6,32 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { AccessItemDTO, CompanyDTO } from "../../../../common"; -import { accessApi, accessKeys } from "../../../access"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { FolderMappingHistoryDialog } from "../../history"; -import { FolderFeedback, FolderMappingEditorDialog, folderApi, folderKeys } from "../../shared"; +import { FolderFeedback, FolderMappingEditorDialog } from "../../shared"; +import { useFolderMappingsPageController } from "../controllers"; +import type { FolderMappingsPageProps } from "../types"; -export function FolderMappingsPage({ - company, - csrfToken, - initialProfileId, -}: { - company: CompanyDTO; - csrfToken: string; - initialProfileId?: string; -}) { +export function FolderMappingsPage(props: FolderMappingsPageProps) { + const { company, csrfToken, initialProfileId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + query, + access, + profiles, + profile, + folder, + filtered, + edited, + viewed, + ready, + } = useFolderMappingsPageController(props); const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: folderKeys.company(company.id), - queryFn: () => folderApi.get(company.id), - }); - const access = useQuery({ - queryKey: accessKeys.scope(company.id), - queryFn: () => accessApi.request(company.id), - }); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const items = access.data?.items ?? []; - const mappings = query.data?.mappings ?? []; - const profile = (id: string) => - profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id; - const folder = (id: string) => folderDetails(items, id); - const filtered = mappings.filter( - (item) => - (!initialProfileId || item.values.profileId === initialProfileId) && - [item.values.path, profile(item.values.profileId), folder(item.values.needId).name].some( - (text) => text.toLowerCase().includes(search.trim().toLowerCase()) - ) - ); - const edited = mappings.find((item) => item.id === editor); - const viewed = mappings.find((item) => item.id === view); - const ready = [query.data, access.data, profiles.data].every(Boolean); return (
); } -export function folderDetails(items: readonly AccessItemDTO[], id: string) { - const need = items.find((item) => item.id === id)?.values; - if (need?.kind !== "need") { - return { name: id, permissions: "" }; - } - const resource = items.find((item) => item.id === need.resourceId)?.values; - return { - name: resource?.kind === "resource" ? resource.name : need.resourceId, - permissions: need.permissionIds - .map((permission) => { - const item = items.find((item) => item.id === permission)?.values; - return item?.kind === "permission" ? item.name : permission; - }) - .join(" · "), - }; -} diff --git a/modules/companies/src/web/folder-mappings/shared/controllers/index.ts b/modules/companies/src/web/folder-mappings/shared/controllers/index.ts new file mode 100644 index 0000000..1e45774 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useFolderMappingFormController } from "./use-folder-mapping-form-controller"; diff --git a/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts b/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts new file mode 100644 index 0000000..a617ffc --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/controllers/use-folder-mapping-form-controller.ts @@ -0,0 +1,113 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { AccessResponseDTO } from "../../../../common"; +import type { FolderMappingInput } from "../../../../common/folder-mappings"; +import { + FOLDER_STATE_TRANSITIONS, + FolderMappingInputSchema, +} from "../../../../common/folder-mappings"; +import { getAccess } from "../../../access/list/api"; +import { accessKeys } from "../../../access/shared/queries"; +import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; +import { folderApi, folderKeys } from "../api"; +import { pathForNeed } from "../helpers"; +import type { FolderMappingFormProps } from "../types"; + +export function useFolderMappingFormController({ + company, + csrfToken, + mapping, + onSaved, + initialProfileId = "", +}: FolderMappingFormProps) { + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState<"profile" | "need">(); + const access = useQuery({ + queryKey: accessKeys.scope(company.id), + queryFn: () => getAccess(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(FolderMappingInputSchema), + defaultValues: mapping?.values ?? { + profileId: initialProfileId, + needId: "", + path: "", + driveLetter: null, + state: "pending", + notes: null, + }, + }); + const values = form.watch(); + const items = access.data?.items ?? []; + const profile = profiles.data?.profiles.find((item) => item.id === values.profileId); + const currentPath = pathForNeed(items, values.needId); + const changeNeed = (id: string) => { + form.setValue("needId", id); + form.setValue("path", pathForNeed(items, id)); + }; + const mutation = useMutation({ + mutationFn: (input: FolderMappingInput) => + folderApi.save(company.id, input, csrfToken, mapping?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: folderKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const ready = [access.data, profiles.data].every(Boolean); + const needs = items.filter( + (item) => + item.values.kind === "need" && + (item.values.personId === profile?.values.personId || item.id === values.needId) + ); + const states = mapping + ? FOLDER_STATE_TRANSITIONS[mapping.values.state] + : ["pending", "configured"]; + const handleQuickCreated = (field: "profileId" | "needId", id: string) => { + if (!mounted.current) { + return; + } + form.setValue(field, id); + setQuick(undefined); + if (field === "needId") { + const updated = client.getQueryData(accessKeys.scope(company.id)); + form.setValue("path", pathForNeed(updated?.items ?? items, id)); + return; + } + changeNeed(""); + }; + const submit = form.handleSubmit((input) => mutation.mutate(input)); + return { + quick, + setQuick, + access, + profiles, + form, + values, + items, + profile, + currentPath, + changeNeed, + mutation, + ready, + needs, + states, + handleQuickCreated, + submit, + }; +} diff --git a/modules/companies/src/web/folder-mappings/shared/helpers/folder-mapping-form-values.ts b/modules/companies/src/web/folder-mappings/shared/helpers/folder-mapping-form-values.ts new file mode 100644 index 0000000..18c4425 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/helpers/folder-mapping-form-values.ts @@ -0,0 +1,10 @@ +import type { AccessItemDTO } from "../../../../common"; + +export function pathForNeed(items: readonly AccessItemDTO[], id: string) { + const need = items.find((item) => item.id === id)?.values; + if (need?.kind !== "need") { + return ""; + } + const resource = items.find((item) => item.id === need.resourceId)?.values; + return resource?.kind === "resource" ? (resource.reference ?? "") : ""; +} diff --git a/modules/companies/src/web/folder-mappings/shared/helpers/index.ts b/modules/companies/src/web/folder-mappings/shared/helpers/index.ts new file mode 100644 index 0000000..2fbd31c --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/helpers/index.ts @@ -0,0 +1 @@ +export { pathForNeed } from "./folder-mapping-form-values"; diff --git a/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts b/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts new file mode 100644 index 0000000..13438db --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/types/folder-mapping-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { FolderMappingDTO } from "../../../../common/folder-mappings"; + +export type FolderMappingFormProps = { + company: CompanyDTO; + csrfToken: string; + mapping?: FolderMappingDTO | undefined; + onSaved: () => void; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/folder-mappings/shared/types/index.ts b/modules/companies/src/web/folder-mappings/shared/types/index.ts new file mode 100644 index 0000000..d2eb504 --- /dev/null +++ b/modules/companies/src/web/folder-mappings/shared/types/index.ts @@ -0,0 +1 @@ +export type { FolderMappingFormProps } from "./folder-mapping-form-props"; diff --git a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx index 17e4ec1..93640d9 100644 --- a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx +++ b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -6,111 +5,42 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { AccessItemDTO, AccessResponseDTO, CompanyDTO } from "../../../../common"; -import type { FolderMappingDTO, FolderMappingInput } from "../../../../common/folder-mappings"; -import { - FOLDER_STATE_TRANSITIONS, - FolderMappingInputSchema, -} from "../../../../common/folder-mappings"; -import { accessApi, accessKeys } from "../../../access"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; +import { FolderMappingInputSchema } from "../../../../common/folder-mappings"; import { FolderQuickEditor } from "../../create"; -import { folderApi, folderKeys } from "../api"; +import { useFolderMappingFormController } from "../controllers"; +import type { FolderMappingFormProps } from "../types"; import { FolderFeedback } from "./folder-feedback"; import { FolderMappingTargets } from "./folder-mapping-targets"; -export function FolderMappingForm({ - company, - csrfToken, - mapping, - onSaved, - initialProfileId = "", -}: { - company: CompanyDTO; - csrfToken: string; - mapping?: FolderMappingDTO | undefined; - onSaved: () => void; - initialProfileId?: string; -}) { +export function FolderMappingForm(props: FolderMappingFormProps) { + const { company, csrfToken, mapping, initialProfileId = "" } = props; + const { + quick, + setQuick, + access, + profiles, + form, + values, + items, + profile, + currentPath, + changeNeed, + mutation, + ready, + needs, + states, + handleQuickCreated, + submit, + } = useFolderMappingFormController(props); const { t } = useTranslation("companies"); - const client = useQueryClient(); const prefix = useId(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState<"profile" | "need">(); - const access = useQuery({ - queryKey: accessKeys.scope(company.id), - queryFn: () => accessApi.request(company.id), - }); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const form = useForm({ - resolver: zodResolver(FolderMappingInputSchema), - defaultValues: mapping?.values ?? { - profileId: initialProfileId, - needId: "", - path: "", - driveLetter: null, - state: "pending", - notes: null, - }, - }); - const values = form.watch(); - const items = access.data?.items ?? []; - const profile = profiles.data?.profiles.find((item) => item.id === values.profileId); - const currentPath = pathForNeed(items, values.needId); - const changeNeed = (id: string) => { - form.setValue("needId", id); - form.setValue("path", pathForNeed(items, id)); - }; - const mutation = useMutation({ - mutationFn: (input: FolderMappingInput) => - folderApi.save(company.id, input, csrfToken, mapping?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: folderKeys.company(company.id) }); - if (mounted.current) { - onSaved(); - } - }, - }); - const ready = [access.data, profiles.data].every(Boolean); - const needs = items.filter( - (item) => - item.values.kind === "need" && - (item.values.personId === profile?.values.personId || item.id === values.needId) - ); - const states = mapping - ? FOLDER_STATE_TRANSITIONS[mapping.values.state] - : ["pending", "configured"]; - const handleQuickCreated = (field: "profileId" | "needId", id: string) => { - if (!mounted.current) { - return; - } - form.setValue(field, id); - setQuick(undefined); - if (field === "needId") { - const updated = client.getQueryData(accessKeys.scope(company.id)); - form.setValue("path", pathForNeed(updated?.items ?? items, id)); - return; - } - changeNeed(""); - }; return ( <> - mutation.mutate(input))}> +
); } -function pathForNeed(items: readonly AccessItemDTO[], id: string) { - const need = items.find((item) => item.id === id)?.values; - if (need?.kind !== "need") { - return ""; - } - const resource = items.find((item) => item.id === need.resourceId)?.values; - return resource?.kind === "resource" ? (resource.reference ?? "") : ""; -} diff --git a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx index 7de394a..d755781 100644 --- a/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx +++ b/modules/companies/src/web/folder-mappings/shared/ui/folder-mapping-targets.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import type { AccessItemDTO, CompanyDTO } from "../../../../common"; import type { FolderMappingInput } from "../../../../common/folder-mappings"; import type { WorkProfileDTO } from "../../../../common/work-profiles"; -import { accessLabel } from "../../../access"; +import { accessLabel } from "../../../access/shared/helpers"; export function FolderMappingTargets({ prefix, diff --git a/modules/companies/src/web/index.ts b/modules/companies/src/web/index.ts index 682d82b..5167b04 100644 --- a/modules/companies/src/web/index.ts +++ b/modules/companies/src/web/index.ts @@ -1,4 +1,5 @@ export { AccessWorkspacePage, accessEnglish, accessSpanish } from "./access"; -export { CompaniesPage, companyKeys } from "./companies"; +export { CompaniesPage } from "./companies"; +export { companyKeys } from "./companies/shared/queries"; export { companiesEnglish, companiesSpanish } from "./shared"; export { CompaniesWorkspace } from "./workspace"; diff --git a/modules/companies/src/web/intervention-templates/list/controllers/index.ts b/modules/companies/src/web/intervention-templates/list/controllers/index.ts new file mode 100644 index 0000000..d585838 --- /dev/null +++ b/modules/companies/src/web/intervention-templates/list/controllers/index.ts @@ -0,0 +1 @@ +export { useInterventionTemplatesPageController } from "./use-templates-page-controller"; diff --git a/modules/companies/src/web/intervention-templates/list/controllers/use-templates-page-controller.ts b/modules/companies/src/web/intervention-templates/list/controllers/use-templates-page-controller.ts new file mode 100644 index 0000000..8d4b93d --- /dev/null +++ b/modules/companies/src/web/intervention-templates/list/controllers/use-templates-page-controller.ts @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { interventionTemplateApi, interventionTemplateKeys } from "../../shared/api"; +import type { InterventionTemplatesPageProps } from "../types"; + +export function useInterventionTemplatesPageController({ + scope = "global", +}: InterventionTemplatesPageProps) { + const query = useQuery({ + queryKey: interventionTemplateKeys.list(scope), + queryFn: () => interventionTemplateApi.list(scope), + }); + const [editor, setEditor] = useState<{ + id?: string; + copyId?: string; + }>(); + const [search, setSearch] = useState(""); + const [showInactive, setShowInactive] = useState(true); + const templates = query.data?.templates ?? []; + const selected = templates.find((item) => item.id === editor?.id); + const copied = templates.find((item) => item.id === editor?.copyId); + const visible = templates.filter( + (item) => + (showInactive || item.values.active) && + item.values.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ); + return { + query, + editor, + setEditor, + search, + setSearch, + showInactive, + setShowInactive, + selected, + copied, + visible, + }; +} diff --git a/modules/companies/src/web/intervention-templates/list/types/index.ts b/modules/companies/src/web/intervention-templates/list/types/index.ts new file mode 100644 index 0000000..a7f87dd --- /dev/null +++ b/modules/companies/src/web/intervention-templates/list/types/index.ts @@ -0,0 +1 @@ +export type { InterventionTemplatesPageProps } from "./templates-page-props"; diff --git a/modules/companies/src/web/intervention-templates/list/types/templates-page-props.ts b/modules/companies/src/web/intervention-templates/list/types/templates-page-props.ts new file mode 100644 index 0000000..19aa92e --- /dev/null +++ b/modules/companies/src/web/intervention-templates/list/types/templates-page-props.ts @@ -0,0 +1,4 @@ +export type InterventionTemplatesPageProps = { + scope?: string; + csrfToken: string; +}; diff --git a/modules/companies/src/web/intervention-templates/list/ui/templates-page.tsx b/modules/companies/src/web/intervention-templates/list/ui/templates-page.tsx index 377820c..2a2a278 100644 --- a/modules/companies/src/web/intervention-templates/list/ui/templates-page.tsx +++ b/modules/companies/src/web/intervention-templates/list/ui/templates-page.tsx @@ -8,42 +8,31 @@ import { EntityTable, } from "@repo/shadcn-ui/components/entity-list"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useQuery } from "@tanstack/react-query"; -import { useId, useState } from "react"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; import { TemplateSteps } from "../../detail"; import { TemplateHistory } from "../../history"; -import { - InterventionTemplateEditorDialog, - InterventionTemplateFeedback, - interventionTemplateApi, - interventionTemplateKeys, -} from "../../shared"; -export function InterventionTemplatesPage({ - scope = "global", - csrfToken, -}: { - scope?: string; - csrfToken: string; -}) { +import { InterventionTemplateEditorDialog, InterventionTemplateFeedback } from "../../shared"; +import { useInterventionTemplatesPageController } from "../controllers"; +import type { InterventionTemplatesPageProps } from "../types"; + +export function InterventionTemplatesPage(props: InterventionTemplatesPageProps) { + const { scope = "global", csrfToken } = props; + const { + query, + editor, + setEditor, + search, + setSearch, + showInactive, + setShowInactive, + selected, + copied, + visible, + } = useInterventionTemplatesPageController(props); const { t, i18n } = useTranslation("companies"); const prefix = useId(); - const query = useQuery({ - queryKey: interventionTemplateKeys.list(scope), - queryFn: () => interventionTemplateApi.list(scope), - }); - const [editor, setEditor] = useState<{ id?: string; copyId?: string }>(); - const [search, setSearch] = useState(""); - const [showInactive, setShowInactive] = useState(true); - const templates = query.data?.templates ?? []; - const selected = templates.find((item) => item.id === editor?.id); - const copied = templates.find((item) => item.id === editor?.copyId); - const visible = templates.filter( - (item) => - (showInactive || item.values.active) && - item.values.name.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ); return (
UniqueID.generateNewID().toPrimitive()); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(InterventionTemplateInputSchema), + defaultValues: template?.values ?? + initialValues ?? { name: "", description: "", active: true, steps: [newTemplateStep()] }, + }); + const steps = useFieldArray({ control: form.control, name: "steps", keyName: "fieldKey" }); + const mutation = useMutation({ + mutationFn: (values: InterventionTemplateInput) => + interventionTemplateApi.save( + scope, + template?.id, + { values, requestKey, expectedRevisionId: template?.history.at(-1)?.id ?? null }, + csrfToken + ), + onError: async (error) => { + if (error instanceof InterventionTemplateApiError && error.code === "TEMPLATE_CHANGED") { + await client.invalidateQueries({ queryKey: ["intervention-templates"] }); + } + }, + onSuccess: async (response) => { + await client.invalidateQueries({ queryKey: ["intervention-templates"] }); + if (mounted.current) { + onSaved(response.template.id); + } + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, steps, mutation, submit }; +} diff --git a/modules/companies/src/web/intervention-templates/shared/helpers/index.ts b/modules/companies/src/web/intervention-templates/shared/helpers/index.ts new file mode 100644 index 0000000..0e90017 --- /dev/null +++ b/modules/companies/src/web/intervention-templates/shared/helpers/index.ts @@ -0,0 +1 @@ +export { copyTemplateValues, newTemplateStep } from "./template-form-values"; diff --git a/modules/companies/src/web/intervention-templates/shared/helpers/template-form-values.ts b/modules/companies/src/web/intervention-templates/shared/helpers/template-form-values.ts new file mode 100644 index 0000000..bead377 --- /dev/null +++ b/modules/companies/src/web/intervention-templates/shared/helpers/template-form-values.ts @@ -0,0 +1,30 @@ +import { UniqueID } from "@repo/rdx-ddd"; + +import type { + InterventionTemplateDTO, + InterventionTemplateInput, +} from "../../../../common/intervention-templates"; + +export function newTemplateStep() { + return { + id: UniqueID.generateNewID().toPrimitive(), + title: "", + instructions: "", + required: false, + }; +} + +export function copyTemplateValues( + template: InterventionTemplateDTO, + name: string +): InterventionTemplateInput { + return { + ...template.values, + name, + active: true, + steps: template.values.steps.map((step) => ({ + ...step, + id: UniqueID.generateNewID().toPrimitive(), + })), + }; +} diff --git a/modules/companies/src/web/intervention-templates/shared/templates.test.tsx b/modules/companies/src/web/intervention-templates/shared/templates.test.tsx index 8eef7ca..c87c88d 100644 --- a/modules/companies/src/web/intervention-templates/shared/templates.test.tsx +++ b/modules/companies/src/web/intervention-templates/shared/templates.test.tsx @@ -13,7 +13,8 @@ import { companiesEnglish, companiesSpanish } from "../../shared"; import { InterventionTemplatesPage } from "../list"; import { interventionTemplateKeys } from "./api"; -import { InterventionTemplateForm, copyTemplateValues } from "./ui"; +import { copyTemplateValues } from "./helpers"; +import { InterventionTemplateForm } from "./ui"; const companyId = UniqueID.generateNewID().toPrimitive(); function template(): InterventionTemplateDTO { diff --git a/modules/companies/src/web/intervention-templates/shared/types/index.ts b/modules/companies/src/web/intervention-templates/shared/types/index.ts new file mode 100644 index 0000000..188628f --- /dev/null +++ b/modules/companies/src/web/intervention-templates/shared/types/index.ts @@ -0,0 +1 @@ +export type { InterventionTemplateFormProps } from "./template-form-props"; diff --git a/modules/companies/src/web/intervention-templates/shared/types/template-form-props.ts b/modules/companies/src/web/intervention-templates/shared/types/template-form-props.ts new file mode 100644 index 0000000..b338079 --- /dev/null +++ b/modules/companies/src/web/intervention-templates/shared/types/template-form-props.ts @@ -0,0 +1,12 @@ +import type { + InterventionTemplateDTO, + InterventionTemplateInput, +} from "../../../../common/intervention-templates"; + +export type InterventionTemplateFormProps = { + scope: string; + template?: InterventionTemplateDTO | undefined; + initialValues?: InterventionTemplateInput | undefined; + csrfToken: string; + onSaved: (id: string) => void; +}; diff --git a/modules/companies/src/web/intervention-templates/shared/ui/index.ts b/modules/companies/src/web/intervention-templates/shared/ui/index.ts index 94c5898..d559868 100644 --- a/modules/companies/src/web/intervention-templates/shared/ui/index.ts +++ b/modules/companies/src/web/intervention-templates/shared/ui/index.ts @@ -1,3 +1,3 @@ export { InterventionTemplateEditorDialog } from "./intervention-template-editor-dialog"; export { InterventionTemplateFeedback } from "./template-feedback"; -export { InterventionTemplateForm, copyTemplateValues } from "./template-form"; +export { InterventionTemplateForm } from "./template-form"; diff --git a/modules/companies/src/web/intervention-templates/shared/ui/intervention-template-editor-dialog.tsx b/modules/companies/src/web/intervention-templates/shared/ui/intervention-template-editor-dialog.tsx index cb0e1e6..9c5eb79 100644 --- a/modules/companies/src/web/intervention-templates/shared/ui/intervention-template-editor-dialog.tsx +++ b/modules/companies/src/web/intervention-templates/shared/ui/intervention-template-editor-dialog.tsx @@ -2,7 +2,9 @@ import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; -import { InterventionTemplateForm, copyTemplateValues } from "./template-form"; +import { copyTemplateValues } from "../helpers"; + +import { InterventionTemplateForm } from "./template-form"; export function InterventionTemplateEditorDialog({ editor, diff --git a/modules/companies/src/web/intervention-templates/shared/ui/template-form.tsx b/modules/companies/src/web/intervention-templates/shared/ui/template-form.tsx index f79369c..367fee4 100644 --- a/modules/companies/src/web/intervention-templates/shared/ui/template-form.tsx +++ b/modules/companies/src/web/intervention-templates/shared/ui/template-form.tsx @@ -1,101 +1,26 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { UniqueID } from "@repo/rdx-ddd"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useFieldArray, useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { - InterventionTemplateDTO, - InterventionTemplateInput, -} from "../../../../common/intervention-templates"; -import { InterventionTemplateInputSchema } from "../../../../common/intervention-templates"; -import { InterventionTemplateApiError, interventionTemplateApi } from "../api"; +import { useInterventionTemplateFormController } from "../controllers"; +import { newTemplateStep } from "../helpers"; +import type { InterventionTemplateFormProps } from "../types"; import { InterventionTemplateFeedback } from "./template-feedback"; import { InterventionTemplateHelp } from "./template-help"; import { TemplateStepFields } from "./template-step-fields"; -export function newTemplateStep() { - return { - id: UniqueID.generateNewID().toPrimitive(), - title: "", - instructions: "", - required: false, - }; -} -export function copyTemplateValues( - template: InterventionTemplateDTO, - name: string -): InterventionTemplateInput { - return { - ...template.values, - name, - active: true, - steps: template.values.steps.map((step) => ({ - ...step, - id: UniqueID.generateNewID().toPrimitive(), - })), - }; -} -export function InterventionTemplateForm({ - scope, - template, - initialValues, - csrfToken, - onSaved, -}: { - scope: string; - template?: InterventionTemplateDTO | undefined; - initialValues?: InterventionTemplateInput | undefined; - csrfToken: string; - onSaved: (id: string) => void; -}) { +export function InterventionTemplateForm(props: InterventionTemplateFormProps) { + const { form, steps, mutation, submit } = useInterventionTemplateFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive()); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const form = useForm({ - resolver: zodResolver(InterventionTemplateInputSchema), - defaultValues: template?.values ?? - initialValues ?? { name: "", description: "", active: true, steps: [newTemplateStep()] }, - }); - const steps = useFieldArray({ control: form.control, name: "steps", keyName: "fieldKey" }); - const mutation = useMutation({ - mutationFn: (values: InterventionTemplateInput) => - interventionTemplateApi.save( - scope, - template?.id, - { values, requestKey, expectedRevisionId: template?.history.at(-1)?.id ?? null }, - csrfToken - ), - onError: async (error) => { - if (error instanceof InterventionTemplateApiError && error.code === "TEMPLATE_CHANGED") { - await client.invalidateQueries({ queryKey: ["intervention-templates"] }); - } - }, - onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: ["intervention-templates"] }); - if (mounted.current) { - onSaved(response.template.id); - } - }, - }); return ( - mutation.mutate(values))}> +
diff --git a/modules/companies/src/web/interventions/create/ui/ticket-intervention-form.tsx b/modules/companies/src/web/interventions/create/ui/ticket-intervention-form.tsx index f7d180b..6d01a8e 100644 --- a/modules/companies/src/web/interventions/create/ui/ticket-intervention-form.tsx +++ b/modules/companies/src/web/interventions/create/ui/ticket-intervention-form.tsx @@ -3,12 +3,8 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { TicketDTO } from "../../../../common/tickets"; -import { - InterventionFeedback, - InterventionForm, - interventionApi, - interventionKeys, -} from "../../shared"; +import { InterventionFeedback, InterventionForm } from "../../shared"; +import { interventionApi, interventionKeys } from "../../shared/api"; export function TicketInterventionForm({ ticket, diff --git a/modules/companies/src/web/interventions/detail/helpers/index.ts b/modules/companies/src/web/interventions/detail/helpers/index.ts new file mode 100644 index 0000000..d9919f3 --- /dev/null +++ b/modules/companies/src/web/interventions/detail/helpers/index.ts @@ -0,0 +1 @@ +export { stepCompletion } from "./step-completion"; diff --git a/modules/companies/src/web/interventions/detail/helpers/step-completion.ts b/modules/companies/src/web/interventions/detail/helpers/step-completion.ts new file mode 100644 index 0000000..ec52eb4 --- /dev/null +++ b/modules/companies/src/web/interventions/detail/helpers/step-completion.ts @@ -0,0 +1,17 @@ +import type { InterventionDTO } from "../../../../common/interventions"; + +export function stepCompletion(intervention: InterventionDTO, stepId: string) { + let completed: InterventionDTO["history"][number] | undefined; + let previousState: string | undefined; + for (const revision of intervention.history) { + const step = revision.values.steps.find((item) => item.id === stepId); + if (step?.state === "done" && previousState !== "done") { + completed = revision; + } + if (step?.state !== "done") { + completed = undefined; + } + previousState = step?.state; + } + return completed; +} diff --git a/modules/companies/src/web/interventions/detail/index.ts b/modules/companies/src/web/interventions/detail/index.ts index 0a6f722..a494a91 100644 --- a/modules/companies/src/web/interventions/detail/index.ts +++ b/modules/companies/src/web/interventions/detail/index.ts @@ -1 +1,2 @@ -export { InterventionDetailDialog, stepCompletion } from "./ui"; +export { stepCompletion } from "./helpers"; +export { InterventionDetailDialog } from "./ui"; diff --git a/modules/companies/src/web/interventions/detail/ui/index.ts b/modules/companies/src/web/interventions/detail/ui/index.ts index 2134769..dd90478 100644 --- a/modules/companies/src/web/interventions/detail/ui/index.ts +++ b/modules/companies/src/web/interventions/detail/ui/index.ts @@ -1,2 +1 @@ -export { stepCompletion } from "./intervention-detail"; export { InterventionDetailDialog } from "./intervention-detail-dialog"; diff --git a/modules/companies/src/web/interventions/detail/ui/intervention-detail.tsx b/modules/companies/src/web/interventions/detail/ui/intervention-detail.tsx index f41d9b9..b9699f2 100644 --- a/modules/companies/src/web/interventions/detail/ui/intervention-detail.tsx +++ b/modules/companies/src/web/interventions/detail/ui/intervention-detail.tsx @@ -2,26 +2,11 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { InterventionDTO, InterventionListDTO } from "../../../../common/interventions"; -import { interventionApi, interventionKeys } from "../../shared"; +import { interventionApi, interventionKeys } from "../../shared/api"; +import { stepCompletion } from "../helpers"; import { InterventionSnapshot } from "./intervention-snapshot"; -export function stepCompletion(intervention: InterventionDTO, stepId: string) { - let completed: InterventionDTO["history"][number] | undefined; - let previousState: string | undefined; - for (const revision of intervention.history) { - const step = revision.values.steps.find((item) => item.id === stepId); - if (step?.state === "done" && previousState !== "done") { - completed = revision; - } - if (step?.state !== "done") { - completed = undefined; - } - previousState = step?.state; - } - return completed; -} - export function InterventionDetail({ intervention, data, diff --git a/modules/companies/src/web/interventions/detail/ui/intervention-snapshot.tsx b/modules/companies/src/web/interventions/detail/ui/intervention-snapshot.tsx index 75ec741..d8cecc3 100644 --- a/modules/companies/src/web/interventions/detail/ui/intervention-snapshot.tsx +++ b/modules/companies/src/web/interventions/detail/ui/intervention-snapshot.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import type { InterventionInput, InterventionListDTO } from "../../../../common/interventions"; import type { TicketLookupsDTO } from "../../../../common/tickets"; -import { ticketTargetOptions } from "../../../tickets"; +import { ticketTargetOptions } from "../../../tickets/shared/helpers"; export function InterventionSnapshot({ values, diff --git a/modules/companies/src/web/interventions/index.ts b/modules/companies/src/web/interventions/index.ts index b890477..1df9d8d 100644 --- a/modules/companies/src/web/interventions/index.ts +++ b/modules/companies/src/web/interventions/index.ts @@ -1,2 +1,3 @@ export { InterventionsPage } from "./list"; -export { InterventionForm, interventionApi, interventionKeys } from "./shared"; +export { InterventionForm } from "./shared"; +export { interventionApi, interventionKeys } from "./shared/api"; diff --git a/modules/companies/src/web/interventions/list/controllers/index.ts b/modules/companies/src/web/interventions/list/controllers/index.ts new file mode 100644 index 0000000..16145ab --- /dev/null +++ b/modules/companies/src/web/interventions/list/controllers/index.ts @@ -0,0 +1 @@ +export { useInterventionsPageController } from "./use-interventions-page-controller"; diff --git a/modules/companies/src/web/interventions/list/controllers/use-interventions-page-controller.ts b/modules/companies/src/web/interventions/list/controllers/use-interventions-page-controller.ts new file mode 100644 index 0000000..80dee7c --- /dev/null +++ b/modules/companies/src/web/interventions/list/controllers/use-interventions-page-controller.ts @@ -0,0 +1,58 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { InterventionDTO } from "../../../../common/interventions"; +import { interventionApi, interventionKeys } from "../../shared/api"; +import { filterInterventions } from "../helpers"; +import type { InterventionFilters, InterventionsPageProps } from "../types"; + +function formatDate(value: string | undefined, language: string) { + return value + ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format( + new Date(value) + ) + : ""; +} +export function useInterventionsPageController({ + companyId, + initialId, + initialCreate = false, + initialPendingOnly = false, +}: InterventionsPageProps) { + const { i18n } = useTranslation("companies"); + const query = useQuery({ + queryKey: interventionKeys.list(companyId), + queryFn: () => interventionApi.list(companyId), + }); + const [filters, setFilters] = useState({ + search: "", + company: "", + state: "", + dateFrom: "", + dateTo: "", + assignee: "", + }); + const [pendingOnly, setPendingOnly] = useState(initialPendingOnly); + const [viewId, setViewId] = useState(initialId); + const [open, setOpen] = useState(initialCreate); + const [edit, setEdit] = useState(); + const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId); + const items = filterInterventions(query.data?.interventions ?? [], filters, pendingOnly); + const date = (value: string | undefined) => formatDate(value, i18n.language); + return { + query, + filters, + setFilters, + pendingOnly, + setPendingOnly, + setViewId, + open, + setOpen, + edit, + setEdit, + currentView, + items, + date, + }; +} diff --git a/modules/companies/src/web/interventions/list/helpers/index.ts b/modules/companies/src/web/interventions/list/helpers/index.ts new file mode 100644 index 0000000..3f96f0e --- /dev/null +++ b/modules/companies/src/web/interventions/list/helpers/index.ts @@ -0,0 +1 @@ +export { filterInterventions } from "./interventions-filtering"; diff --git a/modules/companies/src/web/interventions/list/helpers/interventions-filtering.ts b/modules/companies/src/web/interventions/list/helpers/interventions-filtering.ts new file mode 100644 index 0000000..dda74fb --- /dev/null +++ b/modules/companies/src/web/interventions/list/helpers/interventions-filtering.ts @@ -0,0 +1,39 @@ +import type { InterventionDTO } from "../../../../common/interventions"; +import type { InterventionFilters } from "../types"; + +export function filterInterventions( + items: readonly InterventionDTO[], + filters: InterventionFilters, + pendingOnly = false +) { + const search = filters.search.trim().toLocaleLowerCase(); + return items + .filter( + (item) => + !(pendingOnly && ["finished", "cancelled"].includes(item.values.state)) && + (!filters.company || item.companyId === filters.company) && + (!filters.state || item.values.state === filters.state) && + matchesPlannedDate(item.values.plannedDate, filters.dateFrom, filters.dateTo) && + (!filters.assignee || + (filters.assignee === "unassigned" + ? item.values.assigneeId === null + : item.values.assigneeId === filters.assignee)) && + (!search || + [item.reference, item.values.subject, item.companyName, item.assigneeName ?? ""].some( + (text) => text.toLocaleLowerCase().includes(search) + )) + ) + .sort((a, b) => + (b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "") + ); +} + +export function matchesPlannedDate(date: string | null, from: string, to: string) { + if (!(from || to)) { + return true; + } + if (!date) { + return false; + } + return (!from || date >= from) && (!to || date <= to); +} diff --git a/modules/companies/src/web/interventions/list/index.ts b/modules/companies/src/web/interventions/list/index.ts index 14f96b1..48903de 100644 --- a/modules/companies/src/web/interventions/list/index.ts +++ b/modules/companies/src/web/interventions/list/index.ts @@ -1 +1,2 @@ -export { InterventionsPage, filterInterventions } from "./ui"; +export { filterInterventions } from "./helpers"; +export { InterventionsPage } from "./ui"; diff --git a/modules/companies/src/web/interventions/list/types/index.ts b/modules/companies/src/web/interventions/list/types/index.ts new file mode 100644 index 0000000..bcb226a --- /dev/null +++ b/modules/companies/src/web/interventions/list/types/index.ts @@ -0,0 +1 @@ +export type { InterventionFilters, InterventionsPageProps } from "./interventions-page-props"; diff --git a/modules/companies/src/web/interventions/list/types/interventions-page-props.ts b/modules/companies/src/web/interventions/list/types/interventions-page-props.ts new file mode 100644 index 0000000..f2dd60b --- /dev/null +++ b/modules/companies/src/web/interventions/list/types/interventions-page-props.ts @@ -0,0 +1,16 @@ +export type InterventionFilters = { + search: string; + company: string; + state: string; + dateFrom: string; + dateTo: string; + assignee: string; +}; + +export type InterventionsPageProps = { + companyId?: string | undefined; + csrfToken: string; + initialId?: string | undefined; + initialCreate?: boolean | undefined; + initialPendingOnly?: boolean | undefined; +}; diff --git a/modules/companies/src/web/interventions/list/ui/index.ts b/modules/companies/src/web/interventions/list/ui/index.ts index e0d070f..9474ae9 100644 --- a/modules/companies/src/web/interventions/list/ui/index.ts +++ b/modules/companies/src/web/interventions/list/ui/index.ts @@ -1 +1 @@ -export { InterventionsPage, filterInterventions } from "./interventions-page"; +export { InterventionsPage } from "./interventions-page"; diff --git a/modules/companies/src/web/interventions/list/ui/interventions-page.tsx b/modules/companies/src/web/interventions/list/ui/interventions-page.tsx index 34a4d03..859c168 100644 --- a/modules/companies/src/web/interventions/list/ui/interventions-page.tsx +++ b/modules/companies/src/web/interventions/list/ui/interventions-page.tsx @@ -7,87 +7,33 @@ import { EntityTable, } from "@repo/shadcn-ui/components/entity-list"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { InterventionDTO } from "../../../../common/interventions"; import { InterventionStateSchema } from "../../../../common/interventions"; import { CompanyIdentity, CompanySelect } from "../../../companies/shared"; import { InterventionDetailDialog } from "../../detail"; -import { - InterventionEditorDialog, - InterventionFeedback, - interventionApi, - interventionKeys, -} from "../../shared"; -export type InterventionFilters = { - search: string; - company: string; - state: string; - dateFrom: string; - dateTo: string; - assignee: string; -}; -export function filterInterventions( - items: readonly InterventionDTO[], - filters: InterventionFilters, - pendingOnly = false -) { - const search = filters.search.trim().toLocaleLowerCase(); - return items - .filter( - (item) => - !(pendingOnly && ["finished", "cancelled"].includes(item.values.state)) && - (!filters.company || item.companyId === filters.company) && - (!filters.state || item.values.state === filters.state) && - matchesPlannedDate(item.values.plannedDate, filters.dateFrom, filters.dateTo) && - (!filters.assignee || - (filters.assignee === "unassigned" - ? item.values.assigneeId === null - : item.values.assigneeId === filters.assignee)) && - (!search || - [item.reference, item.values.subject, item.companyName, item.assigneeName ?? ""].some( - (text) => text.toLocaleLowerCase().includes(search) - )) - ) - .sort((a, b) => - (b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "") - ); -} -export function InterventionsPage({ - companyId, - csrfToken, - initialId, - initialCreate = false, - initialPendingOnly = false, -}: { - companyId?: string | undefined; - csrfToken: string; - initialId?: string | undefined; - initialCreate?: boolean | undefined; - initialPendingOnly?: boolean | undefined; -}) { +import { InterventionEditorDialog, InterventionFeedback } from "../../shared"; +import { useInterventionsPageController } from "../controllers"; +import type { InterventionsPageProps } from "../types"; + +export function InterventionsPage(props: InterventionsPageProps) { + const { companyId, csrfToken } = props; + const { + query, + filters, + setFilters, + pendingOnly, + setPendingOnly, + setViewId, + open, + setOpen, + edit, + setEdit, + currentView, + items, + date, + } = useInterventionsPageController(props); const { t, i18n } = useTranslation("companies"); - const query = useQuery({ - queryKey: interventionKeys.list(companyId), - queryFn: () => interventionApi.list(companyId), - }); - const [filters, setFilters] = useState({ - search: "", - company: "", - state: "", - dateFrom: "", - dateTo: "", - assignee: "", - }); - const [pendingOnly, setPendingOnly] = useState(initialPendingOnly); - const [viewId, setViewId] = useState(initialId); - const [open, setOpen] = useState(initialCreate); - const [edit, setEdit] = useState(); - const currentView = query.data?.interventions.find((intervention) => intervention.id === viewId); - const items = filterInterventions(query.data?.interventions ?? [], filters, pendingOnly); - const date = (value: string | undefined) => formatDate(value, i18n.language); return (
); } - -function matchesPlannedDate(date: string | null, from: string, to: string) { - if (!(from || to)) { - return true; - } - if (!date) { - return false; - } - return (!from || date >= from) && (!to || date <= to); -} - -function formatDate(value: string | undefined, language: string) { - return value - ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format( - new Date(value) - ) - : ""; -} diff --git a/modules/companies/src/web/interventions/list/ui/ticket-interventions.tsx b/modules/companies/src/web/interventions/list/ui/ticket-interventions.tsx index b359446..61b4ba5 100644 --- a/modules/companies/src/web/interventions/list/ui/ticket-interventions.tsx +++ b/modules/companies/src/web/interventions/list/ui/ticket-interventions.tsx @@ -1,7 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { InterventionFeedback, interventionApi, interventionKeys } from "../../shared"; +import { InterventionFeedback } from "../../shared"; +import { interventionApi, interventionKeys } from "../../shared/api"; export function TicketInterventions({ companyId, diff --git a/modules/companies/src/web/interventions/shared/controllers/index.ts b/modules/companies/src/web/interventions/shared/controllers/index.ts new file mode 100644 index 0000000..f6754ab --- /dev/null +++ b/modules/companies/src/web/interventions/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useInterventionFormController } from "./use-intervention-form-controller"; diff --git a/modules/companies/src/web/interventions/shared/controllers/use-intervention-form-controller.ts b/modules/companies/src/web/interventions/shared/controllers/use-intervention-form-controller.ts new file mode 100644 index 0000000..7f45857 --- /dev/null +++ b/modules/companies/src/web/interventions/shared/controllers/use-intervention-form-controller.ts @@ -0,0 +1,139 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { UniqueID } from "@repo/rdx-ddd"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { InterventionInput, SaveInterventionInput } from "../../../../common/interventions"; +import { InterventionInputSchema } from "../../../../common/interventions"; +import { InterventionApiError, interventionApi, interventionKeys } from "../api"; +import { emptyIntervention } from "../helpers"; +import type { InterventionFormProps } from "../types"; + +export function useInterventionFormController({ + scope, + intervention, + initialTicket, + data, + csrfToken, + onSaved, +}: InterventionFormProps) { + const client = useQueryClient(); + const [companyId, setCompanyId] = useState( + intervention?.companyId ?? initialTicket?.companyId ?? scope ?? "" + ); + const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive()); + const [quick, setQuick] = useState<"company" | "ticket" | "template">(); + const [selectedTemplate, setSelectedTemplate] = useState(""); + const [applied, setApplied] = useState(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(InterventionInputSchema), + defaultValues: intervention?.values ?? emptyIntervention(initialTicket), + }); + const values = form.watch(); + const lookups = useQuery({ + queryKey: interventionKeys.lookups(companyId), + queryFn: () => interventionApi.lookups(companyId), + enabled: Boolean(companyId), + }); + const mutation = useMutation({ + mutationFn: (input: InterventionInput) => + interventionApi.save( + companyId, + { + values: input, + requestKey, + expectedRevisionId: intervention?.history.at(-1)?.id ?? null, + template: intervention ? null : applied, + }, + csrfToken, + intervention?.id + ), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: ["interventions"] }); + if (mounted.current) { + onSaved(); + } + }, + onError: async (error) => { + if (error instanceof InterventionApiError && error.code === "INTERVENTION_CHANGED") { + await client.invalidateQueries({ queryKey: ["interventions"] }); + } + if (error instanceof InterventionApiError && error.code === "TEMPLATE_CHANGED") { + await client.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) }); + } + }, + }); + const changeCompany = (id: string) => { + setCompanyId(id); + form.setValue("ticketId", null); + form.setValue("affected", []); + form.setValue("steps", []); + setApplied(null); + setSelectedTemplate(""); + }; + const applyTemplate = () => { + const template = lookups.data?.templates.find( + (item) => item.id === selectedTemplate && item.values.active + ); + if (!template) { + setApplied(null); + form.setValue("steps", []); + return; + } + setApplied({ id: template.id, revisionId: template.history.at(-1)?.id ?? "" }); + form.setValue( + "steps", + template.values.steps.map((step) => ({ + ...step, + id: UniqueID.generateNewID().toPrimitive(), + state: "pending", + notes: "", + })) + ); + }; + const selectTicket = (id: string) => { + form.setValue("ticketId", id || null); + const ticket = lookups.data?.tickets.find((item) => item.id === id); + if (!ticket) { + return; + } + if (!form.getValues("subject").trim()) { + form.setValue("subject", ticket.values.subject); + } + if (!form.getValues("affected").length) { + form.setValue("affected", ticket.values.affected); + } + if ( + !form.getValues("assigneeId") && + data.technicians.some((item) => item.id === ticket.values.assigneeId && item.active) + ) { + form.setValue("assigneeId", ticket.values.assigneeId); + } + }; + const submit = form.handleSubmit((input) => mutation.mutate(input)); + return { + client, + companyId, + quick, + setQuick, + selectedTemplate, + setSelectedTemplate, + applied, + form, + values, + lookups, + mutation, + changeCompany, + applyTemplate, + selectTicket, + submit, + }; +} diff --git a/modules/companies/src/web/interventions/shared/helpers/index.ts b/modules/companies/src/web/interventions/shared/helpers/index.ts new file mode 100644 index 0000000..01fb400 --- /dev/null +++ b/modules/companies/src/web/interventions/shared/helpers/index.ts @@ -0,0 +1,2 @@ +export { toLocalDateTime } from "./intervention-date-time"; +export { emptyIntervention } from "./intervention-form-values"; diff --git a/modules/companies/src/web/interventions/shared/helpers/intervention-date-time.ts b/modules/companies/src/web/interventions/shared/helpers/intervention-date-time.ts new file mode 100644 index 0000000..78f1bf7 --- /dev/null +++ b/modules/companies/src/web/interventions/shared/helpers/intervention-date-time.ts @@ -0,0 +1,7 @@ +export function toLocalDateTime(value: string | null) { + if (!value) { + return ""; + } + const date = new Date(value); + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} diff --git a/modules/companies/src/web/interventions/shared/helpers/intervention-form-values.ts b/modules/companies/src/web/interventions/shared/helpers/intervention-form-values.ts new file mode 100644 index 0000000..8ae1e6a --- /dev/null +++ b/modules/companies/src/web/interventions/shared/helpers/intervention-form-values.ts @@ -0,0 +1,21 @@ +import type { InterventionInput } from "../../../../common/interventions"; +import type { TicketDTO } from "../../../../common/tickets"; + +export function emptyIntervention(ticket?: TicketDTO): InterventionInput { + return { + subject: ticket?.values.subject ?? "", + ticketId: ticket?.id ?? null, + assigneeId: ticket?.values.assigneeId ?? null, + state: "pending", + modality: "remote", + plannedDate: null, + startedAt: null, + endedAt: null, + minutes: null, + diagnosis: "", + workPerformed: "", + resolution: "", + affected: ticket?.values.affected.map((item) => ({ ...item })) ?? [], + steps: [], + }; +} diff --git a/modules/companies/src/web/interventions/shared/interventions.test.tsx b/modules/companies/src/web/interventions/shared/interventions.test.tsx index 632e0d8..9f9726a 100644 --- a/modules/companies/src/web/interventions/shared/interventions.test.tsx +++ b/modules/companies/src/web/interventions/shared/interventions.test.tsx @@ -10,11 +10,13 @@ import { I18nextProvider } from "react-i18next"; import type { InterventionDTO, InterventionListDTO } from "../../../common/interventions"; import { companiesEnglish, companiesSpanish } from "../../shared"; -import { stepCompletion } from "../detail"; -import { InterventionsPage, filterInterventions } from "../list"; +import { stepCompletion } from "../detail/helpers"; +import { InterventionsPage } from "../list"; +import { filterInterventions } from "../list/helpers"; import { interventionKeys } from "./api"; -import { InterventionForm, emptyIntervention } from "./ui"; +import { emptyIntervention } from "./helpers"; +import { InterventionForm } from "./ui"; const companyId = UniqueID.generateNewID().toPrimitive(); const data: InterventionListDTO = { diff --git a/modules/companies/src/web/interventions/shared/types/index.ts b/modules/companies/src/web/interventions/shared/types/index.ts new file mode 100644 index 0000000..136849f --- /dev/null +++ b/modules/companies/src/web/interventions/shared/types/index.ts @@ -0,0 +1 @@ +export type { InterventionFormProps } from "./intervention-form-props"; diff --git a/modules/companies/src/web/interventions/shared/types/intervention-form-props.ts b/modules/companies/src/web/interventions/shared/types/intervention-form-props.ts new file mode 100644 index 0000000..9c2cbce --- /dev/null +++ b/modules/companies/src/web/interventions/shared/types/intervention-form-props.ts @@ -0,0 +1,11 @@ +import type { InterventionDTO, InterventionListDTO } from "../../../../common/interventions"; +import type { TicketDTO } from "../../../../common/tickets"; + +export type InterventionFormProps = { + scope?: string; + intervention?: InterventionDTO; + initialTicket?: TicketDTO; + data: InterventionListDTO; + csrfToken: string; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/interventions/shared/ui/index.ts b/modules/companies/src/web/interventions/shared/ui/index.ts index c049fa7..5250f59 100644 --- a/modules/companies/src/web/interventions/shared/ui/index.ts +++ b/modules/companies/src/web/interventions/shared/ui/index.ts @@ -1,3 +1,3 @@ export { InterventionEditorDialog } from "./intervention-editor-dialog"; export { InterventionFeedback } from "./intervention-feedback"; -export { InterventionForm, emptyIntervention } from "./intervention-form"; +export { InterventionForm } from "./intervention-form"; diff --git a/modules/companies/src/web/interventions/shared/ui/intervention-fields.tsx b/modules/companies/src/web/interventions/shared/ui/intervention-fields.tsx index bf3e3e7..f5c7050 100644 --- a/modules/companies/src/web/interventions/shared/ui/intervention-fields.tsx +++ b/modules/companies/src/web/interventions/shared/ui/intervention-fields.tsx @@ -10,6 +10,8 @@ import { InterventionModalitySchema, InterventionStateSchema, } from "../../../../common/interventions"; +import { toLocalDateTime } from "../helpers"; + export function InterventionWorkFields({ form, prefix, @@ -113,10 +115,3 @@ export function InterventionWorkFields({ ); } -export function toLocalDateTime(value: string | null) { - if (!value) { - return ""; - } - const date = new Date(value); - return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}T${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; -} diff --git a/modules/companies/src/web/interventions/shared/ui/intervention-form.tsx b/modules/companies/src/web/interventions/shared/ui/intervention-form.tsx index d809904..7615f83 100644 --- a/modules/companies/src/web/interventions/shared/ui/intervention-form.tsx +++ b/modules/companies/src/web/interventions/shared/ui/intervention-form.tsx @@ -1,27 +1,19 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { UniqueID } from "@repo/rdx-ddd"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { - InterventionDTO, - InterventionInput, - InterventionListDTO, - SaveInterventionInput, -} from "../../../../common/interventions"; -import { InterventionInputSchema } from "../../../../common/interventions"; +import type { InterventionDTO } from "../../../../common/interventions"; import type { TicketDTO } from "../../../../common/tickets"; import { InterventionTemplateCreateDialog } from "../../../intervention-templates"; import { InterventionTicketCreateDialog, TicketQuickForms } from "../../../tickets/create"; import { TicketTargets } from "../../../tickets/shared"; -import { InterventionApiError, interventionApi, interventionKeys } from "../api"; +import { interventionKeys } from "../api"; +import { useInterventionFormController } from "../controllers"; +import type { InterventionFormProps } from "../types"; import { InterventionCompanyField } from "./intervention-company-field"; import { InterventionFeedback } from "./intervention-feedback"; @@ -30,145 +22,37 @@ import { InterventionHelp } from "./intervention-help"; import { InterventionProcedureFields } from "./intervention-procedure-fields"; import { InterventionSteps } from "./intervention-steps"; -export function emptyIntervention(ticket?: TicketDTO): InterventionInput { - return { - subject: ticket?.values.subject ?? "", - ticketId: ticket?.id ?? null, - assigneeId: ticket?.values.assigneeId ?? null, - state: "pending", - modality: "remote", - plannedDate: null, - startedAt: null, - endedAt: null, - minutes: null, - diagnosis: "", - workPerformed: "", - resolution: "", - affected: ticket?.values.affected.map((item) => ({ ...item })) ?? [], - steps: [], - }; +function isCompanyFixed( + scope: string | undefined, + intervention: InterventionDTO | undefined, + ticket: TicketDTO | undefined +) { + return Boolean(scope || intervention || ticket); } - -export function InterventionForm({ - scope, - intervention, - initialTicket, - data, - csrfToken, - onSaved, -}: { - scope?: string; - intervention?: InterventionDTO; - initialTicket?: TicketDTO; - data: InterventionListDTO; - csrfToken: string; - onSaved: () => void; -}) { +export function InterventionForm(props: InterventionFormProps) { + const { scope, intervention, initialTicket, data, csrfToken } = props; + const { + client, + companyId, + quick, + setQuick, + selectedTemplate, + setSelectedTemplate, + applied, + form, + values, + lookups, + mutation, + changeCompany, + applyTemplate, + selectTicket, + submit, + } = useInterventionFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const [companyId, setCompanyId] = useState( - intervention?.companyId ?? initialTicket?.companyId ?? scope ?? "" - ); - const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive()); - const [quick, setQuick] = useState<"company" | "ticket" | "template">(); - const [selectedTemplate, setSelectedTemplate] = useState(""); - const [applied, setApplied] = useState(null); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const form = useForm({ - resolver: zodResolver(InterventionInputSchema), - defaultValues: intervention?.values ?? emptyIntervention(initialTicket), - }); - const values = form.watch(); - const lookups = useQuery({ - queryKey: interventionKeys.lookups(companyId), - queryFn: () => interventionApi.lookups(companyId), - enabled: Boolean(companyId), - }); - const mutation = useMutation({ - mutationFn: (input: InterventionInput) => - interventionApi.save( - companyId, - { - values: input, - requestKey, - expectedRevisionId: intervention?.history.at(-1)?.id ?? null, - template: intervention ? null : applied, - }, - csrfToken, - intervention?.id - ), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: ["interventions"] }); - if (mounted.current) { - onSaved(); - } - }, - onError: async (error) => { - if (error instanceof InterventionApiError && error.code === "INTERVENTION_CHANGED") { - await client.invalidateQueries({ queryKey: ["interventions"] }); - } - if (error instanceof InterventionApiError && error.code === "TEMPLATE_CHANGED") { - await client.invalidateQueries({ queryKey: interventionKeys.lookups(companyId) }); - } - }, - }); - const changeCompany = (id: string) => { - setCompanyId(id); - form.setValue("ticketId", null); - form.setValue("affected", []); - form.setValue("steps", []); - setApplied(null); - setSelectedTemplate(""); - }; - const applyTemplate = () => { - const template = lookups.data?.templates.find( - (item) => item.id === selectedTemplate && item.values.active - ); - if (!template) { - setApplied(null); - form.setValue("steps", []); - return; - } - setApplied({ id: template.id, revisionId: template.history.at(-1)?.id ?? "" }); - form.setValue( - "steps", - template.values.steps.map((step) => ({ - ...step, - id: UniqueID.generateNewID().toPrimitive(), - state: "pending", - notes: "", - })) - ); - }; - const selectTicket = (id: string) => { - form.setValue("ticketId", id || null); - const ticket = lookups.data?.tickets.find((item) => item.id === id); - if (!ticket) { - return; - } - if (!form.getValues("subject").trim()) { - form.setValue("subject", ticket.values.subject); - } - if (!form.getValues("affected").length) { - form.setValue("affected", ticket.values.affected); - } - if ( - !form.getValues("assigneeId") && - data.technicians.some((item) => item.id === ticket.values.assigneeId && item.active) - ) { - form.setValue("assigneeId", ticket.values.assigneeId); - } - }; return ( <> - mutation.mutate(input))}> +
); } - -function isCompanyFixed( - scope: string | undefined, - intervention: InterventionDTO | undefined, - ticket: TicketDTO | undefined -) { - return Boolean(scope || intervention || ticket); -} diff --git a/modules/companies/src/web/mail-configurations/index.ts b/modules/companies/src/web/mail-configurations/index.ts index a7195b6..7f60b82 100644 --- a/modules/companies/src/web/mail-configurations/index.ts +++ b/modules/companies/src/web/mail-configurations/index.ts @@ -1,2 +1,2 @@ export { MailConfigurationsPage } from "./list"; -export { mailApi, mailKeys } from "./shared"; +export { mailApi, mailKeys } from "./shared/api"; diff --git a/modules/companies/src/web/mail-configurations/list/controllers/index.ts b/modules/companies/src/web/mail-configurations/list/controllers/index.ts new file mode 100644 index 0000000..7853334 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/list/controllers/index.ts @@ -0,0 +1 @@ +export { useMailConfigurationsPageController } from "./use-mail-configurations-page-controller"; diff --git a/modules/companies/src/web/mail-configurations/list/controllers/use-mail-configurations-page-controller.ts b/modules/companies/src/web/mail-configurations/list/controllers/use-mail-configurations-page-controller.ts new file mode 100644 index 0000000..382c646 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/list/controllers/use-mail-configurations-page-controller.ts @@ -0,0 +1,81 @@ +import { inventoryApi, inventoryKeys } from "@metis/assets/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { MailConfigurationDTO } from "../../../../common/mail-configurations"; +import { getMailboxes } from "../../../mailboxes/list/api"; +import { mailboxKeys } from "../../../mailboxes/shared/queries"; +import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; +import { mailApi, mailKeys } from "../../shared/api"; +import type { MailConfigurationsPageProps } from "../types"; + +export function useMailConfigurationsPageController({ + company, + initialProfileId, +}: MailConfigurationsPageProps) { + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: mailKeys.company(company.id), + queryFn: () => mailApi.get(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const mailboxes = useQuery({ + queryKey: mailboxKeys.company(company.id), + queryFn: () => getMailboxes(company.id), + }); + const configurations = query.data?.configurations ?? []; + const edited = configurations.find((item) => item.id === editor); + const viewed = configurations.find((item) => item.id === view); + const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; + const asset = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const target = (values: MailConfigurationDTO["values"]) => { + if ("personId" in values) { + return `${person(values.personId)} · ${asset(values.assetId)}`; + } + const profile = profiles.data?.profiles.find((item) => item.id === values.profileId); + return profile + ? `${profile.values.name} · ${person(profile.values.personId)} · ${asset(profile.values.assetId)}` + : values.profileId; + }; + const address = (item: MailConfigurationDTO) => { + const values = item.values; + return "mailboxId" in values + ? (mailboxes.data?.mailboxes.find((mailbox) => mailbox.id === values.mailboxId)?.values + .address ?? values.address) + : values.address; + }; + const filtered = configurations.filter( + (item) => + (!initialProfileId || + ("profileId" in item.values && item.values.profileId === initialProfileId)) && + [address(item), target(item.values)].some((value) => + value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ) + ); + return { + editor, + setEditor, + setView, + search, + setSearch, + query, + inventory, + profiles, + mailboxes, + edited, + viewed, + target, + address, + filtered, + }; +} diff --git a/modules/companies/src/web/mail-configurations/list/types/index.ts b/modules/companies/src/web/mail-configurations/list/types/index.ts new file mode 100644 index 0000000..978ffc6 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/list/types/index.ts @@ -0,0 +1 @@ +export type { MailConfigurationsPageProps } from "./mail-configurations-page-props"; diff --git a/modules/companies/src/web/mail-configurations/list/types/mail-configurations-page-props.ts b/modules/companies/src/web/mail-configurations/list/types/mail-configurations-page-props.ts new file mode 100644 index 0000000..183e4b5 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/list/types/mail-configurations-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type MailConfigurationsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/mail-configurations/list/ui/mail-configurations-page.tsx b/modules/companies/src/web/mail-configurations/list/ui/mail-configurations-page.tsx index 0e5754c..71bdd78 100644 --- a/modules/companies/src/web/mail-configurations/list/ui/mail-configurations-page.tsx +++ b/modules/companies/src/web/mail-configurations/list/ui/mail-configurations-page.tsx @@ -1,4 +1,3 @@ -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -7,76 +6,32 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { MailConfigurationDTO } from "../../../../common/mail-configurations"; -import { mailboxApi, mailboxKeys } from "../../../mailboxes"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { MailConfigurationHistoryDialog } from "../../history"; -import { MailConfigurationEditorDialog, MailFeedback, mailApi, mailKeys } from "../../shared"; -export function MailConfigurationsPage({ - company, - csrfToken, - initialProfileId, -}: { - company: CompanyDTO; - csrfToken: string; - initialProfileId?: string; -}) { - const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: mailKeys.company(company.id), - queryFn: () => mailApi.get(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const mailboxes = useQuery({ - queryKey: mailboxKeys.company(company.id), - queryFn: () => mailboxApi.request(company.id), - }); - const configurations = query.data?.configurations ?? []; - const edited = configurations.find((item) => item.id === editor); - const viewed = configurations.find((item) => item.id === view); - const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; - const asset = (id: string) => - inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; +import { MailConfigurationEditorDialog, MailFeedback } from "../../shared"; +import { useMailConfigurationsPageController } from "../controllers"; +import type { MailConfigurationsPageProps } from "../types"; - const target = (values: MailConfigurationDTO["values"]) => { - if ("personId" in values) { - return `${person(values.personId)} · ${asset(values.assetId)}`; - } - const profile = profiles.data?.profiles.find((item) => item.id === values.profileId); - return profile - ? `${profile.values.name} · ${person(profile.values.personId)} · ${asset(profile.values.assetId)}` - : values.profileId; - }; - const address = (item: MailConfigurationDTO) => { - const values = item.values; - return "mailboxId" in values - ? (mailboxes.data?.mailboxes.find((mailbox) => mailbox.id === values.mailboxId)?.values - .address ?? values.address) - : values.address; - }; - const filtered = configurations.filter( - (item) => - (!initialProfileId || - ("profileId" in item.values && item.values.profileId === initialProfileId)) && - [address(item), target(item.values)].some((value) => - value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ) - ); +export function MailConfigurationsPage(props: MailConfigurationsPageProps) { + const { company, csrfToken, initialProfileId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + query, + inventory, + profiles, + mailboxes, + edited, + viewed, + target, + address, + filtered, + } = useMailConfigurationsPageController(props); + const { t, i18n } = useTranslation("companies"); return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState<"mailbox" | "profile" | "use">(); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const mailboxes = useQuery({ + queryKey: mailboxKeys.company(company.id), + queryFn: () => getMailboxes(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const existing = configuration?.values; + const legacy = legacyOf(existing); + const form = useForm({ + resolver: zodResolver(MailConfigurationInputSchema), + defaultValues: configurationDefaults(existing, initialProfileId), + }); + const values = form.watch(); + const selectedProfile = profiles.data?.profiles.find((item) => item.id === values.profileId); + const selectedMailbox = mailboxes.data?.mailboxes.find((item) => item.id === values.mailboxId); + const today = new Date().toISOString().slice(0, 10); + const authorized = hasMailboxUse( + mailboxes.data?.uses ?? [], + values.mailboxId, + selectedProfile?.values.personId, + today + ); + const mutation = useMutation({ + mutationFn: (input: MailConfigurationInput) => + mailApi.save(company.id, input, csrfToken, configuration?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: mailKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const fixed = configuration?.legacy === false; + const states = configurationStates(configuration); + const profileLabel = (id: string) => + profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id; + const personLabel = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; + const assetLabel = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const disabled = mutation.isPending; + const ready = [profiles.data, mailboxes.data, inventory.data].every(Boolean); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const quickCreated = (field: "profileId" | "mailboxId", id: string) => { + if (mounted.current) { + form.setValue(field, id); + setQuick(undefined); + } + }; + return { + quick, + setQuick, + profiles, + mailboxes, + inventory, + legacy, + form, + values, + selectedProfile, + selectedMailbox, + authorized, + mutation, + fixed, + states, + profileLabel, + personLabel, + assetLabel, + disabled, + ready, + submit, + quickCreated, + }; +} diff --git a/modules/companies/src/web/mail-configurations/shared/helpers/index.ts b/modules/companies/src/web/mail-configurations/shared/helpers/index.ts new file mode 100644 index 0000000..c770861 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/shared/helpers/index.ts @@ -0,0 +1,7 @@ +export { + configurationDefaults, + hasMailboxUse, + legacyOf, + mailboxOptions, + profileOptions, +} from "./mail-configuration-form-values"; diff --git a/modules/companies/src/web/mail-configurations/shared/helpers/mail-configuration-form-values.ts b/modules/companies/src/web/mail-configurations/shared/helpers/mail-configuration-form-values.ts new file mode 100644 index 0000000..dfa5662 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/shared/helpers/mail-configuration-form-values.ts @@ -0,0 +1,63 @@ +import type { + MailConfigurationDTO, + MailConfigurationInput, +} from "../../../../common/mail-configurations"; +import type { MailboxDTO, MailboxUseDTO } from "../../../../common/mailboxes"; +import type { WorkProfileDTO } from "../../../../common/work-profiles"; + +export function legacyOf(existing: MailConfigurationDTO["values"] | undefined) { + return existing && "personId" in existing ? existing : undefined; +} + +export function configurationDefaults( + existing: MailConfigurationDTO["values"] | undefined, + initialProfileId: string +): MailConfigurationInput { + return { + profileId: existing && "profileId" in existing ? existing.profileId : initialProfileId, + mailboxId: existing && "mailboxId" in existing ? existing.mailboxId : "", + method: existing?.method ?? "imap", + state: existing?.state ?? "pending", + notes: existing?.notes ?? null, + }; +} + +export function profileOptions( + items: readonly WorkProfileDTO[], + selected: string, + legacy: ReturnType +) { + return items.filter( + (item) => + (item.values.active || item.id === selected || Boolean(legacy)) && + (!legacy || + (item.values.personId === legacy.personId && item.values.assetId === legacy.assetId)) + ); +} + +export function mailboxOptions( + items: readonly MailboxDTO[], + selected: string, + legacy: ReturnType +) { + return items.filter( + (item) => + (item.values.active || item.id === selected || Boolean(legacy)) && + (!legacy || item.values.address.toLowerCase() === legacy.address.toLowerCase()) + ); +} + +export function hasMailboxUse( + uses: readonly MailboxUseDTO[], + mailboxId: string, + personId: string | undefined, + today: string +) { + return uses.some( + (use) => + use.values.mailboxId === mailboxId && + use.values.personId === personId && + use.values.startDate <= today && + (use.values.endDate === null || today < use.values.endDate) + ); +} diff --git a/modules/companies/src/web/mail-configurations/shared/mail.test.tsx b/modules/companies/src/web/mail-configurations/shared/mail.test.tsx index a6113a6..47c2150 100644 --- a/modules/companies/src/web/mail-configurations/shared/mail.test.tsx +++ b/modules/companies/src/web/mail-configurations/shared/mail.test.tsx @@ -14,10 +14,11 @@ import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nextProvider } from "react-i18next"; -import { accessKeys } from "../../access"; -import { MailboxForm, MailboxRelations, mailboxKeys } from "../../mailboxes"; +import { accessKeys } from "../../access/shared/queries"; +import { MailboxForm, MailboxRelations } from "../../mailboxes"; +import { mailboxKeys } from "../../mailboxes/shared/queries"; import { companiesEnglish, companiesSpanish } from "../../shared"; -import { profileKeys } from "../../work-profiles"; +import { profileKeys } from "../../work-profiles/shared/api"; import { MailConfigurationsPage } from "../list"; import { mailKeys } from "./api"; diff --git a/modules/companies/src/web/mail-configurations/shared/types/index.ts b/modules/companies/src/web/mail-configurations/shared/types/index.ts new file mode 100644 index 0000000..cfc613a --- /dev/null +++ b/modules/companies/src/web/mail-configurations/shared/types/index.ts @@ -0,0 +1 @@ +export type { MailConfigurationFormProps } from "./mail-configuration-form-props"; diff --git a/modules/companies/src/web/mail-configurations/shared/types/mail-configuration-form-props.ts b/modules/companies/src/web/mail-configurations/shared/types/mail-configuration-form-props.ts new file mode 100644 index 0000000..7454a68 --- /dev/null +++ b/modules/companies/src/web/mail-configurations/shared/types/mail-configuration-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { MailConfigurationDTO } from "../../../../common/mail-configurations"; + +export type MailConfigurationFormProps = { + company: CompanyDTO; + csrfToken: string; + configuration?: MailConfigurationDTO | undefined; + onSaved: () => void; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/mail-configurations/shared/ui/mail-configuration-form.tsx b/modules/companies/src/web/mail-configurations/shared/ui/mail-configuration-form.tsx index 5c0c1ed..974187f 100644 --- a/modules/companies/src/web/mail-configurations/shared/ui/mail-configuration-form.tsx +++ b/modules/companies/src/web/mail-configurations/shared/ui/mail-configuration-form.tsx @@ -1,109 +1,50 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Label } from "@repo/shadcn-ui/components/label"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { - MailConfigurationDTO, - MailConfigurationInput, -} from "../../../../common/mail-configurations"; -import { - MAIL_STATE_TRANSITIONS, - MailConfigurationInputSchema, -} from "../../../../common/mail-configurations"; -import type { MailboxDTO, MailboxUseDTO } from "../../../../common/mailboxes"; -import type { WorkProfileDTO } from "../../../../common/work-profiles"; import { MailboxUsagePrompt } from "../../../mailbox-uses"; -import { mailboxApi, mailboxKeys } from "../../../mailboxes"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { MailQuickEditor } from "../../create"; -import { mailApi, mailKeys } from "../api"; +import { useMailConfigurationFormController } from "../controllers"; +import { mailboxOptions, profileOptions } from "../helpers"; +import type { MailConfigurationFormProps } from "../types"; import { MailConfigurationTargets } from "./mail-configuration-targets"; import { MailFeedback } from "./mail-feedback"; -export function MailConfigurationForm({ - company, - csrfToken, - configuration, - onSaved, - initialProfileId = "", -}: { - company: CompanyDTO; - csrfToken: string; - configuration?: MailConfigurationDTO | undefined; - onSaved: () => void; - initialProfileId?: string; -}) { +export function MailConfigurationForm(props: MailConfigurationFormProps) { + const { company, csrfToken, initialProfileId = "" } = props; + const { + quick, + setQuick, + profiles, + mailboxes, + inventory, + legacy, + form, + values, + selectedProfile, + selectedMailbox, + authorized, + mutation, + fixed, + states, + profileLabel, + personLabel, + assetLabel, + disabled, + ready, + submit, + quickCreated, + } = useMailConfigurationFormController(props); const { t } = useTranslation("companies"); - const client = useQueryClient(); const prefix = useId(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState<"mailbox" | "profile" | "use">(); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const mailboxes = useQuery({ - queryKey: mailboxKeys.company(company.id), - queryFn: () => mailboxApi.request(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const existing = configuration?.values; - const legacy = legacyOf(existing); - const form = useForm({ - resolver: zodResolver(MailConfigurationInputSchema), - defaultValues: configurationDefaults(existing, initialProfileId), - }); - const values = form.watch(); - const selectedProfile = profiles.data?.profiles.find((item) => item.id === values.profileId); - const selectedMailbox = mailboxes.data?.mailboxes.find((item) => item.id === values.mailboxId); - const today = new Date().toISOString().slice(0, 10); - const authorized = hasMailboxUse( - mailboxes.data?.uses ?? [], - values.mailboxId, - selectedProfile?.values.personId, - today - ); - const mutation = useMutation({ - mutationFn: (input: MailConfigurationInput) => - mailApi.save(company.id, input, csrfToken, configuration?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: mailKeys.company(company.id) }); - if (mounted.current) { - onSaved(); - } - }, - }); - const fixed = configuration?.legacy === false; - const states = configurationStates(configuration); - const profileLabel = (id: string) => - profiles.data?.profiles.find((item) => item.id === id)?.values.name ?? id; - const personLabel = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; - const assetLabel = (id: string) => - inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; - const disabled = mutation.isPending; - const ready = [profiles.data, mailboxes.data, inventory.data].every(Boolean); return ( <> - mutation.mutate(input))}> +
{legacy && (

@@ -182,78 +123,10 @@ export function MailConfigurationForm({ kind={quick} mailbox={selectedMailbox} onClose={() => setQuick(undefined)} - onCreated={(field, id) => { - if (mounted.current) { - form.setValue(field, id); - setQuick(undefined); - } - }} + onCreated={quickCreated} profile={selectedProfile} /> )} ); } - -function configurationDefaults( - existing: MailConfigurationDTO["values"] | undefined, - initialProfileId: string -): MailConfigurationInput { - return { - profileId: existing && "profileId" in existing ? existing.profileId : initialProfileId, - mailboxId: existing && "mailboxId" in existing ? existing.mailboxId : "", - method: existing?.method ?? "imap", - state: existing?.state ?? "pending", - notes: existing?.notes ?? null, - }; -} - -function legacyOf(existing: MailConfigurationDTO["values"] | undefined) { - return existing && "personId" in existing ? existing : undefined; -} - -function profileOptions( - items: readonly WorkProfileDTO[], - selected: string, - legacy: ReturnType -) { - return items.filter( - (item) => - (item.values.active || item.id === selected || Boolean(legacy)) && - (!legacy || - (item.values.personId === legacy.personId && item.values.assetId === legacy.assetId)) - ); -} - -function mailboxOptions( - items: readonly MailboxDTO[], - selected: string, - legacy: ReturnType -) { - return items.filter( - (item) => - (item.values.active || item.id === selected || Boolean(legacy)) && - (!legacy || item.values.address.toLowerCase() === legacy.address.toLowerCase()) - ); -} - -function configurationStates(configuration: MailConfigurationDTO | undefined) { - return configuration - ? MAIL_STATE_TRANSITIONS[configuration.values.state] - : ["pending", "configured"]; -} - -function hasMailboxUse( - uses: readonly MailboxUseDTO[], - mailboxId: string, - personId: string | undefined, - today: string -) { - return uses.some( - (use) => - use.values.mailboxId === mailboxId && - use.values.personId === personId && - use.values.startDate <= today && - (use.values.endDate === null || today < use.values.endDate) - ); -} diff --git a/modules/companies/src/web/mailbox-uses/shared/api/index.ts b/modules/companies/src/web/mailbox-uses/shared/api/index.ts new file mode 100644 index 0000000..a09e011 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/api/index.ts @@ -0,0 +1 @@ +export { saveMailboxUse } from "./save-mailbox-use.api"; diff --git a/modules/companies/src/web/mailbox-uses/shared/api/save-mailbox-use.api.ts b/modules/companies/src/web/mailbox-uses/shared/api/save-mailbox-use.api.ts new file mode 100644 index 0000000..1e356a9 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/api/save-mailbox-use.api.ts @@ -0,0 +1,11 @@ +import type { MailboxUseInput } from "../../../../common/mailboxes"; +import { mailboxRequest } from "../../../mailboxes/shared/api/mailbox-request"; + +export function saveMailboxUse( + companyId: string, + values: MailboxUseInput, + token: string, + id?: string +) { + return mailboxRequest(companyId, values, token, id); +} diff --git a/modules/companies/src/web/mailbox-uses/shared/controllers/index.ts b/modules/companies/src/web/mailbox-uses/shared/controllers/index.ts new file mode 100644 index 0000000..aae3c55 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useMailboxUseFormController } from "./use-mailbox-use-form-controller"; diff --git a/modules/companies/src/web/mailbox-uses/shared/controllers/use-mailbox-use-form-controller.ts b/modules/companies/src/web/mailbox-uses/shared/controllers/use-mailbox-use-form-controller.ts new file mode 100644 index 0000000..b04aa25 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/controllers/use-mailbox-use-form-controller.ts @@ -0,0 +1,68 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { MailboxUseInput } from "../../../../common/mailboxes"; +import { MailboxUseInputSchema } from "../../../../common/mailboxes"; +import { mailKeys } from "../../../mail-configurations/shared/api"; +import { getMailboxes } from "../../../mailboxes/list/api"; +import { mailboxKeys } from "../../../mailboxes/shared/queries"; +import { saveMailboxUse } from "../api"; +import { useDefaults } from "../helpers"; +import type { MailboxUseFormProps } from "../types"; + +export function useMailboxUseFormController({ + company, + csrfToken, + use, + initialMailboxId = "", + initialPersonId = "", + onSaved, +}: MailboxUseFormProps) { + const client = useQueryClient(); + const [quick, setQuick] = useState<"person" | "mailbox">(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const query = useQuery({ + queryKey: mailboxKeys.company(company.id), + queryFn: () => getMailboxes(company.id), + }); + const today = new Date().toISOString().slice(0, 10); + const form = useForm({ + resolver: zodResolver(MailboxUseInputSchema), + defaultValues: useDefaults(use, initialMailboxId, initialPersonId, today), + }); + const values = form.watch(); + const mutation = useMutation({ + mutationFn: (input: MailboxUseInput) => saveMailboxUse(company.id, input, csrfToken, use?.id), + onSuccess: async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }), + client.invalidateQueries({ queryKey: mailKeys.company(company.id) }), + ]); + if (mounted.current) { + onSaved(); + } + }, + }); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const personCreated = (id: string) => { + if (mounted.current) { + form.setValue("personId", id); + setQuick(undefined); + } + }; + const mailboxCreated = (id: string) => { + if (mounted.current) { + form.setValue("mailboxId", id); + setQuick(undefined); + } + }; + return { quick, setQuick, query, form, values, mutation, submit, personCreated, mailboxCreated }; +} diff --git a/modules/companies/src/web/mailbox-uses/shared/helpers/index.ts b/modules/companies/src/web/mailbox-uses/shared/helpers/index.ts new file mode 100644 index 0000000..71e5870 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/helpers/index.ts @@ -0,0 +1 @@ +export { useDefaults } from "./mailbox-use-form-values"; diff --git a/modules/companies/src/web/mailbox-uses/shared/helpers/mailbox-use-form-values.ts b/modules/companies/src/web/mailbox-uses/shared/helpers/mailbox-use-form-values.ts new file mode 100644 index 0000000..4aaca1e --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/helpers/mailbox-use-form-values.ts @@ -0,0 +1,14 @@ +import type { MailboxUseDTO, MailboxUseInput } from "../../../../common/mailboxes"; + +export function useDefaults( + existing: MailboxUseDTO | undefined, + mailboxId: string, + personId: string, + today: string +): MailboxUseInput { + if (existing) { + const values = existing.values; + return { ...values, endDate: today > values.startDate ? today : values.startDate }; + } + return { mailboxId, personId, startDate: today, endDate: null }; +} diff --git a/modules/companies/src/web/mailbox-uses/shared/types/index.ts b/modules/companies/src/web/mailbox-uses/shared/types/index.ts new file mode 100644 index 0000000..14f4b83 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/types/index.ts @@ -0,0 +1 @@ +export type { MailboxUseFormProps } from "./mailbox-use-form-props"; diff --git a/modules/companies/src/web/mailbox-uses/shared/types/mailbox-use-form-props.ts b/modules/companies/src/web/mailbox-uses/shared/types/mailbox-use-form-props.ts new file mode 100644 index 0000000..360fad1 --- /dev/null +++ b/modules/companies/src/web/mailbox-uses/shared/types/mailbox-use-form-props.ts @@ -0,0 +1,12 @@ +import type { CompanyDTO } from "../../../../common"; +import type { MailboxUseDTO } from "../../../../common/mailboxes"; + +export type MailboxUseFormProps = { + company: CompanyDTO; + csrfToken: string; + use?: MailboxUseDTO | undefined; + initialMailboxId?: string; + initialPersonId?: string; + onSaved: () => void; + allowQuickCreate?: boolean; +}; diff --git a/modules/companies/src/web/mailbox-uses/shared/ui/mailbox-use-form.tsx b/modules/companies/src/web/mailbox-uses/shared/ui/mailbox-use-form.tsx index f8427d6..11d8f9a 100644 --- a/modules/companies/src/web/mailbox-uses/shared/ui/mailbox-use-form.tsx +++ b/modules/companies/src/web/mailbox-uses/shared/ui/mailbox-use-form.tsx @@ -1,78 +1,37 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { MailboxUseDTO, MailboxUseInput } from "../../../../common/mailboxes"; -import { MailboxUseInputSchema } from "../../../../common/mailboxes"; -import { mailKeys } from "../../../mail-configurations/shared/api"; import { MailFeedback } from "../../../mail-configurations/shared/ui/mail-feedback"; import { MailboxCreateDialog } from "../../../mailboxes/create"; -import { mailboxApi, mailboxKeys } from "../../../mailboxes/shared/api"; import { PersonEditorDialog } from "../../../people"; +import { useMailboxUseFormController } from "../controllers"; +import type { MailboxUseFormProps } from "../types"; -export function MailboxUseForm({ - company, - csrfToken, - use, - initialMailboxId = "", - initialPersonId = "", - onSaved, - allowQuickCreate = true, -}: { - company: CompanyDTO; - csrfToken: string; - use?: MailboxUseDTO | undefined; - initialMailboxId?: string; - initialPersonId?: string; - onSaved: () => void; - allowQuickCreate?: boolean; -}) { +function canCreate(editing: boolean, initial: string, allowed: boolean) { + return !(editing || initial) && allowed; +} +export function MailboxUseForm(props: MailboxUseFormProps) { + const { + company, + csrfToken, + use, + initialMailboxId = "", + initialPersonId = "", + allowQuickCreate = true, + } = props; + const { quick, setQuick, query, form, values, mutation, submit, personCreated, mailboxCreated } = + useMailboxUseFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const [quick, setQuick] = useState<"person" | "mailbox">(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const query = useQuery({ - queryKey: mailboxKeys.company(company.id), - queryFn: () => mailboxApi.request(company.id), - }); - const today = new Date().toISOString().slice(0, 10); - const form = useForm({ - resolver: zodResolver(MailboxUseInputSchema), - defaultValues: useDefaults(use, initialMailboxId, initialPersonId, today), - }); - const values = form.watch(); - const mutation = useMutation({ - mutationFn: (input: MailboxUseInput) => - mailboxApi.request(company.id, input, csrfToken, use?.id), - onSuccess: async () => { - await Promise.all([ - client.invalidateQueries({ queryKey: mailboxKeys.company(company.id) }), - client.invalidateQueries({ queryKey: mailKeys.company(company.id) }), - ]); - if (mounted.current) { - onSaved(); - } - }, - }); return ( <> - mutation.mutate(input))}> +

@@ -163,12 +122,7 @@ export function MailboxUseForm({ setQuick(undefined); } }} - onSaved={(id) => { - if (mounted.current) { - form.setValue("personId", id); - setQuick(undefined); - } - }} + onSaved={personCreated} open requireActive /> @@ -182,31 +136,9 @@ export function MailboxUseForm({ setQuick(undefined); } }} - onSaved={(id) => { - if (mounted.current) { - form.setValue("mailboxId", id); - setQuick(undefined); - } - }} + onSaved={mailboxCreated} /> )} ); } - -function useDefaults( - existing: MailboxUseDTO | undefined, - mailboxId: string, - personId: string, - today: string -): MailboxUseInput { - if (existing) { - const values = existing.values; - return { ...values, endDate: today > values.startDate ? today : values.startDate }; - } - return { mailboxId, personId, startDate: today, endDate: null }; -} - -function canCreate(editing: boolean, initial: string, allowed: boolean) { - return !(editing || initial) && allowed; -} diff --git a/modules/companies/src/web/mailboxes/index.ts b/modules/companies/src/web/mailboxes/index.ts index 42846dc..2b5489c 100644 --- a/modules/companies/src/web/mailboxes/index.ts +++ b/modules/companies/src/web/mailboxes/index.ts @@ -1 +1,2 @@ -export { MailboxForm, MailboxRelations, mailboxApi, mailboxKeys } from "./shared"; +export { MailboxForm, MailboxRelations } from "./shared"; +export { mailboxKeys } from "./shared/queries"; diff --git a/modules/companies/src/web/mailboxes/list/api/get-mailboxes.api.ts b/modules/companies/src/web/mailboxes/list/api/get-mailboxes.api.ts new file mode 100644 index 0000000..53adb49 --- /dev/null +++ b/modules/companies/src/web/mailboxes/list/api/get-mailboxes.api.ts @@ -0,0 +1,5 @@ +import { mailboxRequest } from "../../shared/api/mailbox-request"; + +export function getMailboxes(companyId: string) { + return mailboxRequest(companyId); +} diff --git a/modules/companies/src/web/mailboxes/list/api/index.ts b/modules/companies/src/web/mailboxes/list/api/index.ts new file mode 100644 index 0000000..6c6f5a6 --- /dev/null +++ b/modules/companies/src/web/mailboxes/list/api/index.ts @@ -0,0 +1 @@ +export { getMailboxes } from "./get-mailboxes.api"; diff --git a/modules/companies/src/web/mailboxes/shared/api/index.ts b/modules/companies/src/web/mailboxes/shared/api/index.ts deleted file mode 100644 index 71aa09c..0000000 --- a/modules/companies/src/web/mailboxes/shared/api/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { mailboxApi, mailboxKeys } from "./mailbox-api"; diff --git a/modules/companies/src/web/mailboxes/shared/api/mailbox-api.ts b/modules/companies/src/web/mailboxes/shared/api/mailbox-api.ts deleted file mode 100644 index 6b0000c..0000000 --- a/modules/companies/src/web/mailboxes/shared/api/mailbox-api.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod/v4"; - -import type { MailboxInput, MailboxUseInput } from "../../../../common/mailboxes"; -import { MailboxesResponseSchema } from "../../../../common/mailboxes"; -import { MailApiError } from "../../../mail-configurations/shared/api"; -export const mailboxKeys = { company: (id: string) => ["mailboxes", id] }; -export const mailboxApi = { - async request( - companyId: string, - values?: MailboxInput | MailboxUseInput, - token?: string, - id?: string - ) { - const suffix = values && "mailboxId" in values ? "/uses" : ""; - const response = await fetch( - `/api/mailboxes/${companyId}${suffix}${id ? `/${id}/update` : ""}`, - { - method: values ? "POST" : "GET", - credentials: "same-origin", - headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, - ...(values ? { body: JSON.stringify({ values }) } : {}), - } - ); - const json: unknown = await response.json(); - if (!response.ok) { - const parsed = z.object({ code: z.string() }).safeParse(json); - throw new MailApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR"); - } - return MailboxesResponseSchema.parse(json); - }, -}; diff --git a/modules/companies/src/web/mailboxes/shared/api/mailbox-request.ts b/modules/companies/src/web/mailboxes/shared/api/mailbox-request.ts new file mode 100644 index 0000000..56f87fc --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/api/mailbox-request.ts @@ -0,0 +1,28 @@ +import { z } from "zod/v4"; + +import type { MailboxUseInput } from "../../../../common/mailboxes"; +import { MailboxesResponseSchema } from "../../../../common/mailboxes"; +import { MailApiError } from "../../../mail-configurations/shared/api/mail-api"; + +export async function mailboxRequest( + companyId: string, + values?: MailboxUseInput, + token?: string, + id?: string +) { + const response = await fetch( + `/api/mailboxes/${companyId}${values ? "/uses" : ""}${id ? `/${id}/update` : ""}`, + { + method: values ? "POST" : "GET", + credentials: "same-origin", + headers: values ? { "content-type": "application/json", "x-csrf-token": token ?? "" } : {}, + ...(values ? { body: JSON.stringify({ values }) } : {}), + } + ); + const json: unknown = await response.json(); + if (!response.ok) { + const parsed = z.object({ code: z.string() }).safeParse(json); + throw new MailApiError(parsed.success ? parsed.data.code : "INTERNAL_ERROR"); + } + return MailboxesResponseSchema.parse(json); +} diff --git a/modules/companies/src/web/mailboxes/shared/controllers/index.ts b/modules/companies/src/web/mailboxes/shared/controllers/index.ts new file mode 100644 index 0000000..9f2d5d9 --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useMailboxRelationsController } from "./use-mailbox-relations-controller"; diff --git a/modules/companies/src/web/mailboxes/shared/controllers/use-mailbox-relations-controller.ts b/modules/companies/src/web/mailboxes/shared/controllers/use-mailbox-relations-controller.ts new file mode 100644 index 0000000..82989e8 --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/controllers/use-mailbox-relations-controller.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { getMailboxes } from "../../list/api"; +import { mailboxKeys } from "../queries"; +import type { MailboxRelationsProps } from "../types"; + +export function useMailboxRelationsController({ company, mailboxId }: MailboxRelationsProps) { + const [editor, setEditor] = useState(); + const query = useQuery({ + queryKey: mailboxKeys.company(company.id), + queryFn: () => getMailboxes(company.id), + }); + const uses = query.data?.uses.filter((item) => item.values.mailboxId === mailboxId) ?? []; + const edited = uses.find((item) => item.id === editor); + return { editor, setEditor, query, uses, edited }; +} diff --git a/modules/companies/src/web/mailboxes/shared/index.ts b/modules/companies/src/web/mailboxes/shared/index.ts index 5dddff3..4f00c46 100644 --- a/modules/companies/src/web/mailboxes/shared/index.ts +++ b/modules/companies/src/web/mailboxes/shared/index.ts @@ -1,2 +1,2 @@ -export { mailboxApi, mailboxKeys } from "./api"; +export { mailboxKeys } from "./queries"; export { MailboxForm, MailboxRelations } from "./ui"; diff --git a/modules/companies/src/web/mailboxes/shared/queries/index.ts b/modules/companies/src/web/mailboxes/shared/queries/index.ts new file mode 100644 index 0000000..245545b --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/queries/index.ts @@ -0,0 +1 @@ +export { mailboxKeys } from "./mailbox-query-keys"; diff --git a/modules/companies/src/web/mailboxes/shared/queries/mailbox-query-keys.ts b/modules/companies/src/web/mailboxes/shared/queries/mailbox-query-keys.ts new file mode 100644 index 0000000..fd08297 --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/queries/mailbox-query-keys.ts @@ -0,0 +1 @@ +export const mailboxKeys = { company: (id: string) => ["mailboxes", id] }; diff --git a/modules/companies/src/web/mailboxes/shared/types/index.ts b/modules/companies/src/web/mailboxes/shared/types/index.ts new file mode 100644 index 0000000..d8ca6b1 --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/types/index.ts @@ -0,0 +1 @@ +export type { MailboxRelationsProps } from "./mailbox-relations-props"; diff --git a/modules/companies/src/web/mailboxes/shared/types/mailbox-relations-props.ts b/modules/companies/src/web/mailboxes/shared/types/mailbox-relations-props.ts new file mode 100644 index 0000000..378404d --- /dev/null +++ b/modules/companies/src/web/mailboxes/shared/types/mailbox-relations-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type MailboxRelationsProps = { + company: CompanyDTO; + csrfToken: string; + mailboxId: string; +}; diff --git a/modules/companies/src/web/mailboxes/shared/ui/mailbox-relations.tsx b/modules/companies/src/web/mailboxes/shared/ui/mailbox-relations.tsx index cc13cea..9654ead 100644 --- a/modules/companies/src/web/mailboxes/shared/ui/mailbox-relations.tsx +++ b/modules/companies/src/web/mailboxes/shared/ui/mailbox-relations.tsx @@ -1,29 +1,15 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; import { MailFeedback } from "../../../mail-configurations/shared/ui/mail-feedback"; import { MailboxUseEditorDialog } from "../../../mailbox-uses"; -import { mailboxApi, mailboxKeys } from "../api"; -export function MailboxRelations({ - company, - csrfToken, - mailboxId, -}: { - company: CompanyDTO; - csrfToken: string; - mailboxId: string; -}) { +import { useMailboxRelationsController } from "../controllers"; +import type { MailboxRelationsProps } from "../types"; + +export function MailboxRelations(props: MailboxRelationsProps) { + const { company, csrfToken, mailboxId } = props; + const { editor, setEditor, query, uses, edited } = useMailboxRelationsController(props); const { t } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const query = useQuery({ - queryKey: mailboxKeys.company(company.id), - queryFn: () => mailboxApi.request(company.id), - }); - const uses = query.data?.uses.filter((item) => item.values.mailboxId === mailboxId) ?? []; - const edited = uses.find((item) => item.id === editor); return (
diff --git a/modules/companies/src/web/people/shared/adapters/build-person-request.ts b/modules/companies/src/web/people/shared/adapters/build-person-request.ts new file mode 100644 index 0000000..65e1f3b --- /dev/null +++ b/modules/companies/src/web/people/shared/adapters/build-person-request.ts @@ -0,0 +1,11 @@ +import type { PersonRequestDTO } from "../../../../common"; +import type { PersonFormValues } from "../types"; + +export function buildPersonRequest(values: PersonFormValues): PersonRequestDTO { + return { + ...values, + position: values.position.trim() || null, + email: values.email.trim() || null, + phone: values.phone.trim() || null, + }; +} diff --git a/modules/companies/src/web/people/shared/adapters/index.ts b/modules/companies/src/web/people/shared/adapters/index.ts new file mode 100644 index 0000000..c1021d2 --- /dev/null +++ b/modules/companies/src/web/people/shared/adapters/index.ts @@ -0,0 +1 @@ +export { buildPersonRequest } from "./build-person-request"; diff --git a/modules/companies/src/web/people/shared/api/index.ts b/modules/companies/src/web/people/shared/api/index.ts new file mode 100644 index 0000000..e0dfd2d --- /dev/null +++ b/modules/companies/src/web/people/shared/api/index.ts @@ -0,0 +1 @@ +export { savePerson } from "./save-person.api"; diff --git a/modules/companies/src/web/people/shared/api/save-person.api.ts b/modules/companies/src/web/people/shared/api/save-person.api.ts new file mode 100644 index 0000000..637c728 --- /dev/null +++ b/modules/companies/src/web/people/shared/api/save-person.api.ts @@ -0,0 +1,15 @@ +import { CompanyResponseSchema } from "../../../../common"; +import { companyRequest } from "../../../companies/shared/api/company-request"; + +export const savePerson = ( + companyId: string, + id: string | undefined, + body: unknown, + token: string +) => + companyRequest( + `/${companyId}/people${id ? `/${id}/update` : ""}`, + CompanyResponseSchema, + body, + token + ); diff --git a/modules/companies/src/web/people/shared/controllers/index.ts b/modules/companies/src/web/people/shared/controllers/index.ts new file mode 100644 index 0000000..ed02a60 --- /dev/null +++ b/modules/companies/src/web/people/shared/controllers/index.ts @@ -0,0 +1 @@ +export { usePersonFormController } from "./use-person-form-controller"; diff --git a/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts b/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts new file mode 100644 index 0000000..eb6b5fe --- /dev/null +++ b/modules/companies/src/web/people/shared/controllers/use-person-form-controller.ts @@ -0,0 +1,53 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; + +import { accessKeys } from "../../../access/shared/queries"; +import { companyKeys } from "../../../companies/shared/queries"; +import { remoteKeys } from "../../../remote-connections/shared/api"; +import { teleworkKeys } from "../../../telework-authorizations/shared/api"; +import { buildPersonRequest } from "../adapters"; +import { savePerson } from "../api"; +import { PersonFormSchema } from "../schemas"; +import type { PersonFormProps, PersonFormValues } from "../types"; + +export function usePersonFormController({ + person, + companyId, + csrfToken, + onSaved, +}: PersonFormProps) { + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(PersonFormSchema), + defaultValues: { + name: person?.name ?? "", + position: person?.position ?? "", + email: person?.email ?? "", + phone: person?.phone ?? "", + active: person?.active ?? true, + isContact: person?.isContact ?? false, + }, + }); + const mutation = useMutation({ + mutationFn: (values: PersonFormValues) => + savePerson(companyId, person?.id, buildPersonRequest(values), csrfToken), + onSuccess: async (response) => { + await Promise.all([ + client.invalidateQueries({ queryKey: accessKeys.scope(companyId) }), + client.invalidateQueries({ queryKey: remoteKeys.company(companyId) }), + client.invalidateQueries({ queryKey: teleworkKeys.company(companyId) }), + ]); + client.setQueryData(companyKeys.detail(companyId), response); + // El alta devuelve el agregado con la nueva persona añadida al final. + const saved = person + ? response.company.people.find((item) => item.id === person.id) + : response.company.people.at(-1); + if (saved) { + onSaved(saved.id); + } + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/companies/src/web/people/shared/schemas/index.ts b/modules/companies/src/web/people/shared/schemas/index.ts new file mode 100644 index 0000000..13bf3d5 --- /dev/null +++ b/modules/companies/src/web/people/shared/schemas/index.ts @@ -0,0 +1 @@ +export { PersonFormSchema } from "./person-form.schema"; diff --git a/modules/companies/src/web/people/shared/schemas/person-form.schema.ts b/modules/companies/src/web/people/shared/schemas/person-form.schema.ts new file mode 100644 index 0000000..3a41393 --- /dev/null +++ b/modules/companies/src/web/people/shared/schemas/person-form.schema.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +import { PersonRequestSchema } from "../../../../common"; + +export const PersonFormSchema = PersonRequestSchema.extend({ + position: z.string().max(100), + email: z.union([z.email().max(254), z.literal("")]), + phone: z.string().max(50), +}); diff --git a/modules/companies/src/web/people/shared/types/index.ts b/modules/companies/src/web/people/shared/types/index.ts new file mode 100644 index 0000000..1e7c160 --- /dev/null +++ b/modules/companies/src/web/people/shared/types/index.ts @@ -0,0 +1 @@ +export type { PersonFormProps, PersonFormValues } from "./person-form-props"; diff --git a/modules/companies/src/web/people/shared/types/person-form-props.ts b/modules/companies/src/web/people/shared/types/person-form-props.ts new file mode 100644 index 0000000..2d48066 --- /dev/null +++ b/modules/companies/src/web/people/shared/types/person-form-props.ts @@ -0,0 +1,14 @@ +import type { z } from "zod/v4"; + +import type { PersonDTO } from "../../../../common"; +import type { PersonFormSchema } from "../schemas"; + +export type PersonFormValues = z.infer; + +export type PersonFormProps = { + person?: PersonDTO | undefined; + companyId: string; + csrfToken: string; + onSaved: (id: string) => void; + requireActive?: boolean; +}; diff --git a/modules/companies/src/web/people/shared/ui/person-form.tsx b/modules/companies/src/web/people/shared/ui/person-form.tsx index 1f383f8..fae5aa2 100644 --- a/modules/companies/src/web/people/shared/ui/person-form.tsx +++ b/modules/companies/src/web/people/shared/ui/person-form.tsx @@ -1,91 +1,23 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { PersonDTO } from "../../../../common"; -import { PersonRequestSchema } from "../../../../common"; import { EntityHelp } from "../../../access/help"; -import { accessKeys } from "../../../access/shared/api"; -import { CompanyFeedback, companyApi, companyKeys } from "../../../companies/shared"; -import { remoteKeys } from "../../../remote-connections/shared/api"; -import { teleworkKeys } from "../../../telework-authorizations/shared/api"; +import { CompanyFeedback } from "../../../companies/shared"; +import { usePersonFormController } from "../controllers"; +import type { PersonFormProps } from "../types"; -const PersonFormSchema = PersonRequestSchema.extend({ - position: z.string().max(100), - email: z.union([z.email().max(254), z.literal("")]), - phone: z.string().max(50), -}); -type PersonFormValues = z.infer; -export function PersonForm({ - person, - companyId, - csrfToken, - onSaved, - requireActive = false, -}: { - person?: PersonDTO | undefined; - companyId: string; - csrfToken: string; - onSaved: (id: string) => void; - requireActive?: boolean; -}) { +export function PersonForm(props: PersonFormProps) { + const { person, requireActive = false } = props; + const { form, mutation, submit } = usePersonFormController(props); const { t } = useTranslation("companies"); const id = useId(); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver(PersonFormSchema), - defaultValues: { - name: person?.name ?? "", - position: person?.position ?? "", - email: person?.email ?? "", - phone: person?.phone ?? "", - active: person?.active ?? true, - isContact: person?.isContact ?? false, - }, - }); - const mutation = useMutation({ - mutationFn: (values: PersonFormValues) => - companyApi.person( - companyId, - person?.id, - { - ...values, - position: values.position.trim() || null, - email: values.email.trim() || null, - phone: values.phone.trim() || null, - }, - csrfToken - ), - onSuccess: async (response) => { - await Promise.all([ - client.invalidateQueries({ queryKey: accessKeys.scope(companyId) }), - client.invalidateQueries({ queryKey: remoteKeys.company(companyId) }), - client.invalidateQueries({ queryKey: teleworkKeys.company(companyId) }), - ]); - client.setQueryData(companyKeys.detail(companyId), response); - // El alta devuelve el agregado con la nueva persona añadida al final. - const saved = person - ? response.company.people.find((item) => item.id === person.id) - : response.company.people.at(-1); - if (saved) { - onSaved(saved.id); - } - }, - }); - return ( - mutation.mutate(values))} - > +
diff --git a/modules/companies/src/web/printer-configurations/index.ts b/modules/companies/src/web/printer-configurations/index.ts index b9ee508..66052b1 100644 --- a/modules/companies/src/web/printer-configurations/index.ts +++ b/modules/companies/src/web/printer-configurations/index.ts @@ -1,2 +1,2 @@ export { PrinterConfigurationsPage } from "./list"; -export { printerApi, printerKeys } from "./shared"; +export { printerApi, printerKeys } from "./shared/api"; diff --git a/modules/companies/src/web/printer-configurations/list/controllers/index.ts b/modules/companies/src/web/printer-configurations/list/controllers/index.ts new file mode 100644 index 0000000..1c3e3da --- /dev/null +++ b/modules/companies/src/web/printer-configurations/list/controllers/index.ts @@ -0,0 +1 @@ +export { usePrinterConfigurationsPageController } from "./use-printer-configurations-page-controller"; diff --git a/modules/companies/src/web/printer-configurations/list/controllers/use-printer-configurations-page-controller.ts b/modules/companies/src/web/printer-configurations/list/controllers/use-printer-configurations-page-controller.ts new file mode 100644 index 0000000..2c47e2e --- /dev/null +++ b/modules/companies/src/web/printer-configurations/list/controllers/use-printer-configurations-page-controller.ts @@ -0,0 +1,66 @@ +import { inventoryApi, inventoryKeys } from "@metis/assets/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; +import { printerApi, printerKeys } from "../../shared/api"; +import type { PrinterConfigurationsPageProps } from "../types"; + +export function usePrinterConfigurationsPageController({ + company, + initialProfileId, +}: PrinterConfigurationsPageProps) { + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: printerKeys.company(company.id), + queryFn: () => printerApi.get(company.id), + }); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const configurations = query.data?.configurations ?? []; + const asset = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id); + const label = (id: string) => { + const item = profile(id); + return item + ? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}` + : id; + }; + const filtered = configurations.filter( + (item) => + (!initialProfileId || item.values.profileId === initialProfileId) && + `${asset(item.values.printerId)} ${label(item.values.profileId)}` + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()) + ); + const edited = configurations.find((item) => item.id === editor); + const viewed = configurations.find((item) => item.id === view); + const ready = !!query.data && !!profiles.data && !!inventory.data; + const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; + return { + editor, + setEditor, + setView, + search, + setSearch, + query, + profiles, + inventory, + asset, + label, + filtered, + edited, + viewed, + ready, + canCreate, + }; +} diff --git a/modules/companies/src/web/printer-configurations/list/types/index.ts b/modules/companies/src/web/printer-configurations/list/types/index.ts new file mode 100644 index 0000000..f1ecbf9 --- /dev/null +++ b/modules/companies/src/web/printer-configurations/list/types/index.ts @@ -0,0 +1 @@ +export type { PrinterConfigurationsPageProps } from "./printer-configurations-page-props"; diff --git a/modules/companies/src/web/printer-configurations/list/types/printer-configurations-page-props.ts b/modules/companies/src/web/printer-configurations/list/types/printer-configurations-page-props.ts new file mode 100644 index 0000000..5bd2dcb --- /dev/null +++ b/modules/companies/src/web/printer-configurations/list/types/printer-configurations-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type PrinterConfigurationsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialProfileId?: string; +}; diff --git a/modules/companies/src/web/printer-configurations/list/ui/printer-configurations-page.tsx b/modules/companies/src/web/printer-configurations/list/ui/printer-configurations-page.tsx index 5566371..fd8666b 100644 --- a/modules/companies/src/web/printer-configurations/list/ui/printer-configurations-page.tsx +++ b/modules/companies/src/web/printer-configurations/list/ui/printer-configurations-page.tsx @@ -1,4 +1,3 @@ -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -7,66 +6,33 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { PrinterHistoryDialog } from "../../history"; -import { - PrinterConfigurationEditorDialog, - PrinterFeedback, - printerApi, - printerKeys, -} from "../../shared"; +import { PrinterConfigurationEditorDialog, PrinterFeedback } from "../../shared"; +import { usePrinterConfigurationsPageController } from "../controllers"; +import type { PrinterConfigurationsPageProps } from "../types"; -export function PrinterConfigurationsPage({ - company, - csrfToken, - initialProfileId, -}: { - company: CompanyDTO; - csrfToken: string; - initialProfileId?: string; -}) { +export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps) { + const { company, csrfToken, initialProfileId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + query, + profiles, + inventory, + asset, + label, + filtered, + edited, + viewed, + ready, + canCreate, + } = usePrinterConfigurationsPageController(props); const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: printerKeys.company(company.id), - queryFn: () => printerApi.get(company.id), - }); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const configurations = query.data?.configurations ?? []; - const asset = (id: string) => - inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; - const profile = (id: string) => profiles.data?.profiles.find((item) => item.id === id); - const label = (id: string) => { - const item = profile(id); - return item - ? `${item.values.name} · ${company.people.find((person) => person.id === item.values.personId)?.name ?? ""} · ${asset(item.values.assetId)}` - : id; - }; - const filtered = configurations.filter( - (item) => - (!initialProfileId || item.values.profileId === initialProfileId) && - `${asset(item.values.printerId)} ${label(item.values.profileId)}` - .toLocaleLowerCase() - .includes(search.trim().toLocaleLowerCase()) - ); - const edited = configurations.find((item) => item.id === editor); - const viewed = configurations.find((item) => item.id === view); - const ready = !!query.data && !!profiles.data && !!inventory.data; - const canCreate = !initialProfileId || profile(initialProfileId)?.values.active === true; return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState<"profile" | "printer">(); + const profiles = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(PrinterConfigurationInputSchema), + defaultValues: initialPrinterValues(configuration, initialProfileId), + }); + const values = form.watch(); + const profile = profiles.data?.profiles.find((item) => item.id === values.profileId); + const states = availablePrinterStates(configuration); + const profileFixed = Boolean(configuration || initialProfileId); + const ready = [profiles.data, inventory.data].every(Boolean); + const mutation = useMutation({ + mutationFn: (input: PrinterConfigurationInput) => + printerApi.save(company.id, input, csrfToken, configuration?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: printerKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const changeState = (value: string) => { + const parsed = PrinterConfigurationInputSchema.shape.state.safeParse(value); + if (parsed.success) { + form.setValue("state", parsed.data); + if (!["pending", "configured"].includes(parsed.data)) { + form.setValue("isDefault", false); + } + } + }; + const quickCreated = (id: string) => { + if (mounted.current) { + form.setValue("profileId", id); + form.setValue("printerId", ""); + setQuick(undefined); + } + }; + const printerCreated = (_field: "personId" | "accountId" | "assetId", id: string | undefined) => { + if (mounted.current && id) { + form.setValue("printerId", id); + setQuick(undefined); + } + }; + return { + quick, + setQuick, + profiles, + inventory, + form, + values, + profile, + states, + profileFixed, + ready, + mutation, + submit, + changeState, + quickCreated, + printerCreated, + }; +} diff --git a/modules/companies/src/web/printer-configurations/shared/helpers/index.ts b/modules/companies/src/web/printer-configurations/shared/helpers/index.ts new file mode 100644 index 0000000..70a0230 --- /dev/null +++ b/modules/companies/src/web/printer-configurations/shared/helpers/index.ts @@ -0,0 +1 @@ +export { initialPrinterValues, printerOptions } from "./printer-configuration-form-values"; diff --git a/modules/companies/src/web/printer-configurations/shared/helpers/printer-configuration-form-values.ts b/modules/companies/src/web/printer-configurations/shared/helpers/printer-configuration-form-values.ts new file mode 100644 index 0000000..d33ed57 --- /dev/null +++ b/modules/companies/src/web/printer-configurations/shared/helpers/printer-configuration-form-values.ts @@ -0,0 +1,37 @@ +import type { AssetDTO } from "@metis/assets/common"; + +import type { + PrinterConfigurationDTO, + PrinterConfigurationInput, +} from "../../../../common/printer-configurations"; + +export function printerOptions( + assets: readonly AssetDTO[], + hostId: string | undefined, + selected: string +) { + return assets + .filter( + (item) => item.id !== hostId && (item.values.status !== "retired" || item.id === selected) + ) + .map((item) => ({ + value: item.id, + label: `${item.values.name} · ${item.specification.templateName}`, + })); +} + +export function initialPrinterValues( + configuration: PrinterConfigurationDTO | undefined, + profileId: string +): PrinterConfigurationInput { + return ( + configuration?.values ?? { + profileId, + printerId: "", + connection: "network", + isDefault: false, + state: "pending", + notes: null, + } + ); +} diff --git a/modules/companies/src/web/printer-configurations/shared/printer.test.tsx b/modules/companies/src/web/printer-configurations/shared/printer.test.tsx index d156ab0..b664ee9 100644 --- a/modules/companies/src/web/printer-configurations/shared/printer.test.tsx +++ b/modules/companies/src/web/printer-configurations/shared/printer.test.tsx @@ -9,7 +9,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { I18nextProvider } from "react-i18next"; import { companiesEnglish, companiesSpanish } from "../../shared"; -import { profileKeys } from "../../work-profiles"; +import { profileKeys } from "../../work-profiles/shared/api"; import { PrinterConfigurationsPage } from "../list"; import { printerKeys } from "./api"; diff --git a/modules/companies/src/web/printer-configurations/shared/types/index.ts b/modules/companies/src/web/printer-configurations/shared/types/index.ts new file mode 100644 index 0000000..28ef88a --- /dev/null +++ b/modules/companies/src/web/printer-configurations/shared/types/index.ts @@ -0,0 +1 @@ +export type { PrinterConfigurationFormProps } from "./printer-configuration-form-props"; diff --git a/modules/companies/src/web/printer-configurations/shared/types/printer-configuration-form-props.ts b/modules/companies/src/web/printer-configurations/shared/types/printer-configuration-form-props.ts new file mode 100644 index 0000000..aedc2bd --- /dev/null +++ b/modules/companies/src/web/printer-configurations/shared/types/printer-configuration-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { PrinterConfigurationDTO } from "../../../../common/printer-configurations"; + +export type PrinterConfigurationFormProps = { + company: CompanyDTO; + csrfToken: string; + configuration?: PrinterConfigurationDTO | undefined; + initialProfileId?: string; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/printer-configurations/shared/ui/printer-configuration-form.tsx b/modules/companies/src/web/printer-configurations/shared/ui/printer-configuration-form.tsx index 44923b5..1172c7c 100644 --- a/modules/companies/src/web/printer-configurations/shared/ui/printer-configuration-form.tsx +++ b/modules/companies/src/web/printer-configurations/shared/ui/printer-configuration-form.tsx @@ -1,86 +1,52 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; -import type { AssetDTO } from "@metis/assets/common"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO, WorkProfileDTO } from "../../../../common"; -import type { - PrinterConfigurationDTO, - PrinterConfigurationInput, -} from "../../../../common/printer-configurations"; -import { - PRINTER_STATE_TRANSITIONS, - PrinterConfigurationInputSchema, -} from "../../../../common/printer-configurations"; +import type { WorkProfileDTO } from "../../../../common"; +import { PrinterConfigurationInputSchema } from "../../../../common/printer-configurations"; import { ConfigurationQuickEditor } from "../../../shared"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; import { PrinterProfileCreateDialog } from "../../create"; -import { printerApi, printerKeys } from "../api"; +import { usePrinterConfigurationFormController } from "../controllers"; +import { printerOptions } from "../helpers"; +import type { PrinterConfigurationFormProps } from "../types"; import { PrinterFeedback } from "./printer-feedback"; -export function PrinterConfigurationForm({ - company, - csrfToken, - configuration, - initialProfileId = "", - onSaved, -}: { - company: CompanyDTO; - csrfToken: string; - configuration?: PrinterConfigurationDTO | undefined; - initialProfileId?: string; - onSaved: () => void; -}) { + +function profileOptions(profiles: readonly WorkProfileDTO[], selected: string) { + return profiles + .filter((item) => item.values.active || item.id === selected) + .map((item) => ({ value: item.id, label: item.values.name })); +} + +export function PrinterConfigurationForm(props: PrinterConfigurationFormProps) { + const { company, csrfToken, configuration } = props; + const { + quick, + setQuick, + profiles, + inventory, + form, + values, + profile, + states, + profileFixed, + ready, + mutation, + submit, + changeState, + quickCreated, + printerCreated, + } = usePrinterConfigurationFormController(props); const { t } = useTranslation("companies"); - const client = useQueryClient(); const prefix = useId(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState<"profile" | "printer">(); - const profiles = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const form = useForm({ - resolver: zodResolver(PrinterConfigurationInputSchema), - defaultValues: initialPrinterValues(configuration, initialProfileId), - }); - const values = form.watch(); - const profile = profiles.data?.profiles.find((item) => item.id === values.profileId); - const states = availablePrinterStates(configuration); - const profileFixed = Boolean(configuration || initialProfileId); - const ready = [profiles.data, inventory.data].every(Boolean); - const mutation = useMutation({ - mutationFn: (input: PrinterConfigurationInput) => - printerApi.save(company.id, input, csrfToken, configuration?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: printerKeys.company(company.id) }); - if (mounted.current) { - onSaved(); - } - }, - }); return ( <> - mutation.mutate(input))}> +
@@ -151,15 +117,7 @@ export function PrinterConfigurationForm({ { - const parsed = PrinterConfigurationInputSchema.shape.state.safeParse(value); - if (parsed.success) { - form.setValue("state", parsed.data); - if (!["pending", "configured"].includes(parsed.data)) { - form.setValue("isDefault", false); - } - } - }} + onValueChange={changeState} options={states.map((value) => ({ value, label: t(`mail.${value}`) }))} placeholder={t("mail.choose")} required @@ -216,13 +174,7 @@ export function PrinterConfigurationForm({ setQuick(undefined); } }} - onSaved={(id) => { - if (mounted.current) { - form.setValue("profileId", id); - form.setValue("printerId", ""); - setQuick(undefined); - } - }} + onSaved={quickCreated} /> )} {quick === "printer" && ( @@ -232,12 +184,7 @@ export function PrinterConfigurationForm({ data={undefined} kind="asset" onClose={() => setQuick(undefined)} - onCreated={(_field, id) => { - if (mounted.current && id) { - form.setValue("printerId", id); - setQuick(undefined); - } - }} + onCreated={printerCreated} title={t("printers.newAsset")} values={{ personId: "", accountId: "" }} /> @@ -245,42 +192,3 @@ export function PrinterConfigurationForm({ ); } - -function profileOptions(profiles: readonly WorkProfileDTO[], selected: string) { - return profiles - .filter((item) => item.values.active || item.id === selected) - .map((item) => ({ value: item.id, label: item.values.name })); -} -function printerOptions(assets: readonly AssetDTO[], hostId: string | undefined, selected: string) { - return assets - .filter( - (item) => item.id !== hostId && (item.values.status !== "retired" || item.id === selected) - ) - .map((item) => ({ - value: item.id, - label: `${item.values.name} · ${item.specification.templateName}`, - })); -} - -function initialPrinterValues( - configuration: PrinterConfigurationDTO | undefined, - profileId: string -): PrinterConfigurationInput { - return ( - configuration?.values ?? { - profileId, - printerId: "", - connection: "network", - isDefault: false, - state: "pending", - notes: null, - } - ); -} -function availablePrinterStates( - configuration: PrinterConfigurationDTO | undefined -): readonly PrinterConfigurationInput["state"][] { - return configuration - ? PRINTER_STATE_TRANSITIONS[configuration.values.state] - : ["pending", "configured"]; -} diff --git a/modules/companies/src/web/process-templates/list/api/index.ts b/modules/companies/src/web/process-templates/list/api/index.ts new file mode 100644 index 0000000..ee7b1a3 --- /dev/null +++ b/modules/companies/src/web/process-templates/list/api/index.ts @@ -0,0 +1 @@ +export { listProcessTemplates } from "./list-process-templates.api"; diff --git a/modules/companies/src/web/process-templates/list/api/list-process-templates.api.ts b/modules/companies/src/web/process-templates/list/api/list-process-templates.api.ts new file mode 100644 index 0000000..1abefe4 --- /dev/null +++ b/modules/companies/src/web/process-templates/list/api/list-process-templates.api.ts @@ -0,0 +1,6 @@ +import { ProcessTemplatesResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../../processes/shared/api/process-request"; + +export async function listProcessTemplates() { + return ProcessTemplatesResponseSchema.parse(await processRequest("templates")); +} diff --git a/modules/companies/src/web/process-templates/list/controllers/index.ts b/modules/companies/src/web/process-templates/list/controllers/index.ts new file mode 100644 index 0000000..032a46f --- /dev/null +++ b/modules/companies/src/web/process-templates/list/controllers/index.ts @@ -0,0 +1 @@ +export { useProcessTemplatesPageController } from "./use-process-templates-page-controller"; diff --git a/modules/companies/src/web/process-templates/list/controllers/use-process-templates-page-controller.ts b/modules/companies/src/web/process-templates/list/controllers/use-process-templates-page-controller.ts new file mode 100644 index 0000000..c888b2d --- /dev/null +++ b/modules/companies/src/web/process-templates/list/controllers/use-process-templates-page-controller.ts @@ -0,0 +1,19 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { processKeys } from "../../../processes/shared/queries"; +import { listProcessTemplates } from "../api"; + +export function useProcessTemplatesPageController() { + const query = useQuery({ queryKey: processKeys.catalog(), queryFn: listProcessTemplates }); + const [editor, setEditor] = useState(); + const [copy, setCopy] = useState(); + const templates = query.data?.templates ?? []; + const selected = templates.find((x) => x.id === editor); + const copied = templates.find((x) => x.id === copy); + const close = () => { + setEditor(undefined); + setCopy(undefined); + }; + return { query, editor, setEditor, copy, setCopy, templates, selected, copied, close }; +} diff --git a/modules/companies/src/web/process-templates/list/types/index.ts b/modules/companies/src/web/process-templates/list/types/index.ts new file mode 100644 index 0000000..d48266b --- /dev/null +++ b/modules/companies/src/web/process-templates/list/types/index.ts @@ -0,0 +1 @@ +export type { ProcessTemplatesPageProps } from "./process-templates-page-props"; diff --git a/modules/companies/src/web/process-templates/list/types/process-templates-page-props.ts b/modules/companies/src/web/process-templates/list/types/process-templates-page-props.ts new file mode 100644 index 0000000..6bce299 --- /dev/null +++ b/modules/companies/src/web/process-templates/list/types/process-templates-page-props.ts @@ -0,0 +1,3 @@ +export type ProcessTemplatesPageProps = { + csrfToken: string; +}; diff --git a/modules/companies/src/web/process-templates/list/ui/process-templates-page.tsx b/modules/companies/src/web/process-templates/list/ui/process-templates-page.tsx index 460edb1..b253ec8 100644 --- a/modules/companies/src/web/process-templates/list/ui/process-templates-page.tsx +++ b/modules/companies/src/web/process-templates/list/ui/process-templates-page.tsx @@ -6,24 +6,18 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { ProcessFeedback, processApi, processKeys } from "../../../processes/shared"; +import { ProcessFeedback } from "../../../processes/shared"; import { ProcessTemplateEditorDialog } from "../../shared"; -export function ProcessTemplatesPage({ csrfToken }: { csrfToken: string }) { +import { useProcessTemplatesPageController } from "../controllers"; +import type { ProcessTemplatesPageProps } from "../types"; + +export function ProcessTemplatesPage(props: ProcessTemplatesPageProps) { + const { csrfToken } = props; + const { query, editor, setEditor, copy, setCopy, templates, selected, copied, close } = + useProcessTemplatesPageController(); const { t, i18n } = useTranslation("companies"); - const query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog }); - const [editor, setEditor] = useState(); - const [copy, setCopy] = useState(); - const templates = query.data?.templates ?? []; - const selected = templates.find((x) => x.id === editor); - const copied = templates.find((x) => x.id === copy); - const close = () => { - setEditor(undefined); - setCopy(undefined); - }; return (
line.trim()) + .filter(Boolean), + }; +} diff --git a/modules/companies/src/web/process-templates/shared/adapters/index.ts b/modules/companies/src/web/process-templates/shared/adapters/index.ts new file mode 100644 index 0000000..0e5ed5d --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/adapters/index.ts @@ -0,0 +1 @@ +export { buildProcessTemplateInput } from "./build-process-template-input"; diff --git a/modules/companies/src/web/process-templates/shared/api/index.ts b/modules/companies/src/web/process-templates/shared/api/index.ts new file mode 100644 index 0000000..8e856c0 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/api/index.ts @@ -0,0 +1 @@ +export { saveProcessTemplate } from "./save-process-template.api"; diff --git a/modules/companies/src/web/process-templates/shared/api/save-process-template.api.ts b/modules/companies/src/web/process-templates/shared/api/save-process-template.api.ts new file mode 100644 index 0000000..81fa916 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/api/save-process-template.api.ts @@ -0,0 +1,13 @@ +import type { ProcessTemplateInput } from "../../../../common/processes"; +import { ProcessTemplateResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../../processes/shared/api/process-request"; + +export async function saveProcessTemplate( + values: ProcessTemplateInput, + token: string, + id?: string +) { + return ProcessTemplateResponseSchema.parse( + await processRequest(`templates${id ? `/${id}/update` : ""}`, { values }, token) + ); +} diff --git a/modules/companies/src/web/process-templates/shared/controllers/index.ts b/modules/companies/src/web/process-templates/shared/controllers/index.ts new file mode 100644 index 0000000..c436758 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useProcessTemplateFormController } from "./use-process-template-form-controller"; diff --git a/modules/companies/src/web/process-templates/shared/controllers/use-process-template-form-controller.ts b/modules/companies/src/web/process-templates/shared/controllers/use-process-template-form-controller.ts new file mode 100644 index 0000000..a3e7d62 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/controllers/use-process-template-form-controller.ts @@ -0,0 +1,52 @@ +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 { processKeys } from "../../../processes/shared/queries"; +import { buildProcessTemplateInput } from "../adapters"; +import { saveProcessTemplate } from "../api"; +import { FormSchema } from "../schemas"; +import type { ProcessTemplateFormProps, Values } from "../types"; + +export function useProcessTemplateFormController({ + template, + initialValues, + csrfToken, + onSaved, +}: ProcessTemplateFormProps) { + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const initial = template?.values ?? initialValues; + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: initial?.name ?? "", + kind: initial?.kind ?? "onboarding", + active: initial?.active ?? true, + notes: initial?.notes ?? null, + lines: initial?.instructions.join("\n") ?? "", + }, + }); + const mutation = useMutation({ + mutationFn: (v: Values) => + saveProcessTemplate(buildProcessTemplateInput(v), csrfToken, template?.id), + onSuccess: async (response) => { + await client.invalidateQueries({ queryKey: processKeys.catalog() }); + if (mounted.current) { + onSaved(response.template.id); + } + }, + }); + const submit = form.handleSubmit((v) => { + const { lines, ...values } = v; + mutation.mutate({ ...values, lines }); + }); + return { form, mutation, submit }; +} diff --git a/modules/companies/src/web/process-templates/shared/schemas/index.ts b/modules/companies/src/web/process-templates/shared/schemas/index.ts new file mode 100644 index 0000000..07af7d9 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/schemas/index.ts @@ -0,0 +1 @@ +export { FormSchema } from "./process-template-form.schema"; diff --git a/modules/companies/src/web/process-templates/shared/schemas/process-template-form.schema.ts b/modules/companies/src/web/process-templates/shared/schemas/process-template-form.schema.ts new file mode 100644 index 0000000..0a8890c --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/schemas/process-template-form.schema.ts @@ -0,0 +1,19 @@ +import { z } from "zod/v4"; + +import { ProcessTemplateInputSchema } from "../../../../common/processes"; + +export const FormSchema = z.strictObject({ + name: ProcessTemplateInputSchema.shape.name, + kind: ProcessTemplateInputSchema.shape.kind, + active: ProcessTemplateInputSchema.shape.active, + notes: ProcessTemplateInputSchema.shape.notes, + lines: z.string().refine( + (value) => + ProcessTemplateInputSchema.shape.instructions.safeParse( + value + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + ).success + ), +}); diff --git a/modules/companies/src/web/process-templates/shared/types/index.ts b/modules/companies/src/web/process-templates/shared/types/index.ts new file mode 100644 index 0000000..e85c005 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/types/index.ts @@ -0,0 +1 @@ +export type { ProcessTemplateFormProps, Values } from "./process-template-form-props"; diff --git a/modules/companies/src/web/process-templates/shared/types/process-template-form-props.ts b/modules/companies/src/web/process-templates/shared/types/process-template-form-props.ts new file mode 100644 index 0000000..d97f666 --- /dev/null +++ b/modules/companies/src/web/process-templates/shared/types/process-template-form-props.ts @@ -0,0 +1,13 @@ +import type { z } from "zod/v4"; + +import type { ProcessTemplateDTO, ProcessTemplateInput } from "../../../../common/processes"; +import type { FormSchema } from "../schemas"; + +export type Values = z.infer; + +export type ProcessTemplateFormProps = { + template?: ProcessTemplateDTO | undefined; + initialValues?: ProcessTemplateInput | undefined; + csrfToken: string; + onSaved: (id: string) => void; +}; diff --git a/modules/companies/src/web/process-templates/shared/ui/process-template-form.tsx b/modules/companies/src/web/process-templates/shared/ui/process-template-form.tsx index 993d9a1..0bae4b4 100644 --- a/modules/companies/src/web/process-templates/shared/ui/process-template-form.tsx +++ b/modules/companies/src/web/process-templates/shared/ui/process-template-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -6,95 +5,20 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { ProcessTemplateDTO, ProcessTemplateInput } from "../../../../common/processes"; import { ProcessTemplateInputSchema } from "../../../../common/processes"; -import { ProcessFeedback, ProcessHelp, processApi, processKeys } from "../../../processes/shared"; +import { ProcessFeedback, ProcessHelp } from "../../../processes/shared"; +import { useProcessTemplateFormController } from "../controllers"; +import type { ProcessTemplateFormProps } from "../types"; -const FormSchema = z.strictObject({ - name: ProcessTemplateInputSchema.shape.name, - kind: ProcessTemplateInputSchema.shape.kind, - active: ProcessTemplateInputSchema.shape.active, - notes: ProcessTemplateInputSchema.shape.notes, - lines: z.string().refine( - (value) => - ProcessTemplateInputSchema.shape.instructions.safeParse( - value - .split("\n") - .map((s) => s.trim()) - .filter(Boolean) - ).success - ), -}); -type Values = z.infer; -export function ProcessTemplateForm({ - template, - initialValues, - csrfToken, - onSaved, -}: { - template?: ProcessTemplateDTO | undefined; - initialValues?: ProcessTemplateInput | undefined; - csrfToken: string; - onSaved: (id: string) => void; -}) { +export function ProcessTemplateForm(props: ProcessTemplateFormProps) { + const { form, mutation, submit } = useProcessTemplateFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const initial = template?.values ?? initialValues; - const form = useForm({ - resolver: zodResolver(FormSchema), - defaultValues: { - name: initial?.name ?? "", - kind: initial?.kind ?? "onboarding", - active: initial?.active ?? true, - notes: initial?.notes ?? null, - lines: initial?.instructions.join("\n") ?? "", - }, - }); - const mutation = useMutation({ - mutationFn: (v: Values) => - processApi.saveTemplate( - { - name: v.name, - kind: v.kind, - active: v.active, - notes: v.notes, - instructions: v.lines - .split("\n") - .map((s) => s.trim()) - .filter(Boolean), - }, - csrfToken, - template?.id - ), - onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: processKeys.catalog() }); - if (mounted.current) { - onSaved(response.template.id); - } - }, - }); return ( - { - const { lines, ...values } = v; - mutation.mutate({ ...values, lines }); - })} - > +
diff --git a/modules/companies/src/web/processes/detail/controllers/index.ts b/modules/companies/src/web/processes/detail/controllers/index.ts new file mode 100644 index 0000000..51a8015 --- /dev/null +++ b/modules/companies/src/web/processes/detail/controllers/index.ts @@ -0,0 +1 @@ +export { useProcessDetailController } from "./use-process-detail-controller"; diff --git a/modules/companies/src/web/processes/detail/controllers/use-process-detail-controller.ts b/modules/companies/src/web/processes/detail/controllers/use-process-detail-controller.ts new file mode 100644 index 0000000..7fac9a9 --- /dev/null +++ b/modules/companies/src/web/processes/detail/controllers/use-process-detail-controller.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { refreshProcess } from "../../refresh/api"; +import { processKeys } from "../../shared/queries"; +import type { ProcessDetailProps } from "../types"; + +export function useProcessDetailController({ process, csrfToken }: ProcessDetailProps) { + const [edit, setEdit] = useState(false); + const client = useQueryClient(); + const refresh = useMutation({ + mutationFn: () => + refreshProcess(process.companyId, process.id, process.history.at(-1)?.id ?? "", csrfToken), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: processKeys.company(process.companyId) }); + }, + }); + const open = process.values.state === "open"; + const resolved = process.values.tasks.filter((task) => task.status !== "pending").length; + return { edit, setEdit, refresh, open, resolved }; +} diff --git a/modules/companies/src/web/processes/detail/types/index.ts b/modules/companies/src/web/processes/detail/types/index.ts new file mode 100644 index 0000000..7b44060 --- /dev/null +++ b/modules/companies/src/web/processes/detail/types/index.ts @@ -0,0 +1 @@ +export type { ProcessDetailProps } from "./process-detail-props"; diff --git a/modules/companies/src/web/processes/detail/types/process-detail-props.ts b/modules/companies/src/web/processes/detail/types/process-detail-props.ts new file mode 100644 index 0000000..b8d4cfc --- /dev/null +++ b/modules/companies/src/web/processes/detail/types/process-detail-props.ts @@ -0,0 +1,7 @@ +import type { PersonProcessDTO } from "../../../../common/processes"; + +export type ProcessDetailProps = { + process: PersonProcessDTO; + person: string; + csrfToken: string; +}; diff --git a/modules/companies/src/web/processes/detail/ui/process-detail.tsx b/modules/companies/src/web/processes/detail/ui/process-detail.tsx index 24f6d94..d43136c 100644 --- a/modules/companies/src/web/processes/detail/ui/process-detail.tsx +++ b/modules/companies/src/web/processes/detail/ui/process-detail.tsx @@ -1,44 +1,17 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Card, CardContent } from "@repo/shadcn-ui/components/card"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { PersonProcessDTO } from "../../../../common/processes"; -import { - ProcessFeedback, - processApi, - processKeys, - processReferenceTitle, - processTaskTitle, -} from "../../shared"; +import { ProcessFeedback } from "../../shared"; +import { processReferenceTitle, processTaskTitle } from "../../shared/helpers"; import { ProcessEditDialog } from "../../update"; -export function ProcessDetail({ - process, - person, - csrfToken, -}: { - process: PersonProcessDTO; - person: string; - csrfToken: string; -}) { +import { useProcessDetailController } from "../controllers"; +import type { ProcessDetailProps } from "../types"; + +export function ProcessDetail(props: ProcessDetailProps) { + const { process, person, csrfToken } = props; + const { edit, setEdit, refresh, open, resolved } = useProcessDetailController(props); const { t, i18n } = useTranslation("companies"); - const [edit, setEdit] = useState(false); - const client = useQueryClient(); - const refresh = useMutation({ - mutationFn: () => - processApi.refresh( - process.companyId, - process.id, - process.history.at(-1)?.id ?? "", - csrfToken - ), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: processKeys.company(process.companyId) }); - }, - }); - const open = process.values.state === "open"; - const resolved = process.values.tasks.filter((task) => task.status !== "pending").length; return (
diff --git a/modules/companies/src/web/processes/list/api/get-processes.api.ts b/modules/companies/src/web/processes/list/api/get-processes.api.ts new file mode 100644 index 0000000..bce34fe --- /dev/null +++ b/modules/companies/src/web/processes/list/api/get-processes.api.ts @@ -0,0 +1,6 @@ +import { ProcessesResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../shared/api/process-request"; + +export async function getProcesses(company: string) { + return ProcessesResponseSchema.parse(await processRequest(company)); +} diff --git a/modules/companies/src/web/processes/list/api/index.ts b/modules/companies/src/web/processes/list/api/index.ts new file mode 100644 index 0000000..41a353b --- /dev/null +++ b/modules/companies/src/web/processes/list/api/index.ts @@ -0,0 +1 @@ +export { getProcesses } from "./get-processes.api"; diff --git a/modules/companies/src/web/processes/list/controllers/index.ts b/modules/companies/src/web/processes/list/controllers/index.ts new file mode 100644 index 0000000..22d70b8 --- /dev/null +++ b/modules/companies/src/web/processes/list/controllers/index.ts @@ -0,0 +1 @@ +export { useProcessesPageController } from "./use-processes-page-controller"; diff --git a/modules/companies/src/web/processes/list/controllers/use-processes-page-controller.ts b/modules/companies/src/web/processes/list/controllers/use-processes-page-controller.ts new file mode 100644 index 0000000..6fb8bbe --- /dev/null +++ b/modules/companies/src/web/processes/list/controllers/use-processes-page-controller.ts @@ -0,0 +1,46 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { processKeys } from "../../shared/queries"; +import { getProcesses } from "../api"; +import type { ProcessesPageProps } from "../types"; + +export function useProcessesPageController({ company }: ProcessesPageProps) { + const { t } = useTranslation("companies"); + const [templates, setTemplates] = useState(false); + const [create, setCreate] = useState(false); + const [selected, setSelected] = useState(); + const [search, setSearch] = useState(""); + const [closed, setClosed] = useState(false); + const query = useQuery({ + queryKey: processKeys.company(company.id), + queryFn: () => getProcesses(company.id), + }); + const person = (id: string) => company.people.find((p) => p.id === id)?.name ?? id; + const processes = query.data?.processes ?? []; + const current = processes.find((p) => p.id === selected); + const filtered = processes.filter( + (p) => + (closed || p.values.state === "open") && + [p.values.name, person(p.values.personId), t(`processes.${p.values.kind}`)].some((s) => + s.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ) + ); + return { + templates, + setTemplates, + create, + setCreate, + selected, + setSelected, + search, + setSearch, + closed, + setClosed, + query, + person, + current, + filtered, + }; +} diff --git a/modules/companies/src/web/processes/list/types/index.ts b/modules/companies/src/web/processes/list/types/index.ts new file mode 100644 index 0000000..c53a64b --- /dev/null +++ b/modules/companies/src/web/processes/list/types/index.ts @@ -0,0 +1 @@ +export type { ProcessesPageProps } from "./processes-page-props"; diff --git a/modules/companies/src/web/processes/list/types/processes-page-props.ts b/modules/companies/src/web/processes/list/types/processes-page-props.ts new file mode 100644 index 0000000..1609a65 --- /dev/null +++ b/modules/companies/src/web/processes/list/types/processes-page-props.ts @@ -0,0 +1,6 @@ +import type { CompanyDTO } from "../../../../common"; + +export type ProcessesPageProps = { + company: CompanyDTO; + csrfToken: string; +}; diff --git a/modules/companies/src/web/processes/list/ui/processes-page.tsx b/modules/companies/src/web/processes/list/ui/processes-page.tsx index da70cd6..b02d726 100644 --- a/modules/companies/src/web/processes/list/ui/processes-page.tsx +++ b/modules/companies/src/web/processes/list/ui/processes-page.tsx @@ -6,36 +6,34 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; import { ProcessTemplatesPage } from "../../../process-templates"; import { ProcessDetail } from "../../detail"; -import { ProcessFeedback, processApi, processKeys } from "../../shared"; +import { ProcessFeedback } from "../../shared"; import { ProcessStartDialog } from "../../start"; -export function ProcessesPage({ company, csrfToken }: { company: CompanyDTO; csrfToken: string }) { +import { useProcessesPageController } from "../controllers"; +import type { ProcessesPageProps } from "../types"; + +export function ProcessesPage(props: ProcessesPageProps) { + const { company, csrfToken } = props; + const { + templates, + setTemplates, + create, + setCreate, + selected, + setSelected, + search, + setSearch, + closed, + setClosed, + query, + person, + current, + filtered, + } = useProcessesPageController(props); const { t, i18n } = useTranslation("companies"); - const [templates, setTemplates] = useState(false); - const [create, setCreate] = useState(false); - const [selected, setSelected] = useState(); - const [search, setSearch] = useState(""); - const [closed, setClosed] = useState(false); - const query = useQuery({ - queryKey: processKeys.company(company.id), - queryFn: () => processApi.get(company.id), - }); - const person = (id: string) => company.people.find((p) => p.id === id)?.name ?? id; - const processes = query.data?.processes ?? []; - const current = processes.find((p) => p.id === selected); - const filtered = processes.filter( - (p) => - (closed || p.values.state === "open") && - [p.values.name, person(p.values.personId), t(`processes.${p.values.kind}`)].some((s) => - s.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ) - ); if (templates) { return (
diff --git a/modules/companies/src/web/processes/refresh/api/index.ts b/modules/companies/src/web/processes/refresh/api/index.ts new file mode 100644 index 0000000..755231a --- /dev/null +++ b/modules/companies/src/web/processes/refresh/api/index.ts @@ -0,0 +1 @@ +export { refreshProcess } from "./refresh-process.api"; diff --git a/modules/companies/src/web/processes/refresh/api/refresh-process.api.ts b/modules/companies/src/web/processes/refresh/api/refresh-process.api.ts new file mode 100644 index 0000000..a83a274 --- /dev/null +++ b/modules/companies/src/web/processes/refresh/api/refresh-process.api.ts @@ -0,0 +1,13 @@ +import { ProcessResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../shared/api/process-request"; + +export async function refreshProcess( + company: string, + id: string, + revisionId: string, + token: string +) { + return ProcessResponseSchema.parse( + await processRequest(`${company}/${id}/refresh`, { revisionId }, token) + ); +} diff --git a/modules/companies/src/web/processes/shared/api/index.ts b/modules/companies/src/web/processes/shared/api/index.ts index 8ce2928..33ebb0d 100644 --- a/modules/companies/src/web/processes/shared/api/index.ts +++ b/modules/companies/src/web/processes/shared/api/index.ts @@ -1 +1 @@ -export { ProcessApiError, processApi, processKeys } from "./process-api"; +export { ProcessApiError } from "./process-api-error"; diff --git a/modules/companies/src/web/processes/shared/api/process-api-error.ts b/modules/companies/src/web/processes/shared/api/process-api-error.ts new file mode 100644 index 0000000..7626ecf --- /dev/null +++ b/modules/companies/src/web/processes/shared/api/process-api-error.ts @@ -0,0 +1,5 @@ +export class ProcessApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/companies/src/web/processes/shared/api/process-api.ts b/modules/companies/src/web/processes/shared/api/process-api.ts deleted file mode 100644 index 9e54c53..0000000 --- a/modules/companies/src/web/processes/shared/api/process-api.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { z } from "zod/v4"; - -import type { - ProcessTemplateInput, - StartProcessInput, - UpdateProcessInput, -} from "../../../../common/processes"; -import { - ProcessResponseSchema, - ProcessReviewResponseSchema, - ProcessTemplateResponseSchema, - ProcessTemplatesResponseSchema, - ProcessesResponseSchema, -} from "../../../../common/processes"; -export const processKeys = { - company: (id: string) => ["person-processes", id], - catalog: () => ["process-templates"], - review: (company: string, person: string) => ["process-review", company, person], -}; -export class ProcessApiError extends Error { - constructor(public readonly code: string) { - super(code); - } -} -async function request(path: string, body?: unknown, token?: string) { - const response = await fetch(`/api/processes/${path}`, { - method: body === undefined ? "GET" : "POST", - credentials: "same-origin", - headers: - body === undefined ? {} : { "content-type": "application/json", "x-csrf-token": token ?? "" }, - ...(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 ProcessApiError(error.success ? error.data.code : "INTERNAL_ERROR"); - } - return data; -} -export const processApi = { - async catalog() { - return ProcessTemplatesResponseSchema.parse(await request("templates")); - }, - async saveTemplate(values: ProcessTemplateInput, token: string, id?: string) { - return ProcessTemplateResponseSchema.parse( - await request(`templates${id ? `/${id}/update` : ""}`, { values }, token) - ); - }, - async get(company: string) { - return ProcessesResponseSchema.parse(await request(company)); - }, - async review(company: string, person: string) { - return ProcessReviewResponseSchema.parse(await request(`${company}/people/${person}/review`)); - }, - async start(company: string, values: StartProcessInput, token: string) { - return ProcessResponseSchema.parse(await request(company, { values }, token)); - }, - async update(company: string, id: string, values: UpdateProcessInput, token: string) { - return ProcessResponseSchema.parse(await request(`${company}/${id}/update`, { values }, token)); - }, - async refresh(company: string, id: string, revisionId: string, token: string) { - return ProcessResponseSchema.parse( - await request(`${company}/${id}/refresh`, { revisionId }, token) - ); - }, -}; diff --git a/modules/companies/src/web/processes/shared/api/process-request.ts b/modules/companies/src/web/processes/shared/api/process-request.ts new file mode 100644 index 0000000..bef8dbc --- /dev/null +++ b/modules/companies/src/web/processes/shared/api/process-request.ts @@ -0,0 +1,19 @@ +import { z } from "zod/v4"; + +import { ProcessApiError } from "./process-api-error"; + +export async function processRequest(path: string, body?: unknown, token?: string) { + const response = await fetch(`/api/processes/${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "same-origin", + headers: + body === undefined ? {} : { "content-type": "application/json", "x-csrf-token": token ?? "" }, + ...(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 ProcessApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return data; +} diff --git a/modules/companies/src/web/processes/shared/index.ts b/modules/companies/src/web/processes/shared/index.ts index 0c9df32..94d702b 100644 --- a/modules/companies/src/web/processes/shared/index.ts +++ b/modules/companies/src/web/processes/shared/index.ts @@ -1,3 +1,3 @@ -export { processApi, processKeys } from "./api"; export { processReferenceTitle, processTaskTitle } from "./helpers"; +export { processKeys } from "./queries"; export { ProcessFeedback, ProcessHelp } from "./ui"; diff --git a/modules/companies/src/web/processes/shared/processes.test.tsx b/modules/companies/src/web/processes/shared/processes.test.tsx index cf13b7f..180e6cb 100644 --- a/modules/companies/src/web/processes/shared/processes.test.tsx +++ b/modules/companies/src/web/processes/shared/processes.test.tsx @@ -15,7 +15,7 @@ import { ProcessesPage } from "../list"; import { ProcessStartForm } from "../start"; import { ProcessEditForm } from "../update"; -import { processKeys } from "./api"; +import { processKeys } from "./queries"; const company = { id: "0199b200-0000-7000-8000-000000000001", diff --git a/modules/companies/src/web/processes/shared/queries/index.ts b/modules/companies/src/web/processes/shared/queries/index.ts new file mode 100644 index 0000000..4e6b987 --- /dev/null +++ b/modules/companies/src/web/processes/shared/queries/index.ts @@ -0,0 +1 @@ +export { processKeys } from "./process-query-keys"; diff --git a/modules/companies/src/web/processes/shared/queries/process-query-keys.ts b/modules/companies/src/web/processes/shared/queries/process-query-keys.ts new file mode 100644 index 0000000..fb07ad5 --- /dev/null +++ b/modules/companies/src/web/processes/shared/queries/process-query-keys.ts @@ -0,0 +1,5 @@ +export const processKeys = { + company: (id: string) => ["person-processes", id], + catalog: () => ["process-templates"], + review: (company: string, person: string) => ["process-review", company, person], +}; diff --git a/modules/companies/src/web/processes/start/api/get-process-review.api.ts b/modules/companies/src/web/processes/start/api/get-process-review.api.ts new file mode 100644 index 0000000..58a7032 --- /dev/null +++ b/modules/companies/src/web/processes/start/api/get-process-review.api.ts @@ -0,0 +1,8 @@ +import { ProcessReviewResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../shared/api/process-request"; + +export async function getProcessReview(company: string, person: string) { + return ProcessReviewResponseSchema.parse( + await processRequest(`${company}/people/${person}/review`) + ); +} diff --git a/modules/companies/src/web/processes/start/api/index.ts b/modules/companies/src/web/processes/start/api/index.ts new file mode 100644 index 0000000..e211534 --- /dev/null +++ b/modules/companies/src/web/processes/start/api/index.ts @@ -0,0 +1,2 @@ +export { getProcessReview } from "./get-process-review.api"; +export { startProcess } from "./start-process.api"; diff --git a/modules/companies/src/web/processes/start/api/start-process.api.ts b/modules/companies/src/web/processes/start/api/start-process.api.ts new file mode 100644 index 0000000..f362567 --- /dev/null +++ b/modules/companies/src/web/processes/start/api/start-process.api.ts @@ -0,0 +1,7 @@ +import type { StartProcessInput } from "../../../../common/processes"; +import { ProcessResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../shared/api/process-request"; + +export async function startProcess(company: string, values: StartProcessInput, token: string) { + return ProcessResponseSchema.parse(await processRequest(company, { values }, token)); +} diff --git a/modules/companies/src/web/processes/start/controllers/index.ts b/modules/companies/src/web/processes/start/controllers/index.ts new file mode 100644 index 0000000..cf403a1 --- /dev/null +++ b/modules/companies/src/web/processes/start/controllers/index.ts @@ -0,0 +1 @@ +export { useProcessStartFormController } from "./use-process-start-form-controller"; diff --git a/modules/companies/src/web/processes/start/controllers/use-process-start-form-controller.ts b/modules/companies/src/web/processes/start/controllers/use-process-start-form-controller.ts new file mode 100644 index 0000000..d5d71ea --- /dev/null +++ b/modules/companies/src/web/processes/start/controllers/use-process-start-form-controller.ts @@ -0,0 +1,56 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { StartProcessInput } from "../../../../common/processes"; +import { StartProcessInputSchema } from "../../../../common/processes"; +import { listProcessTemplates } from "../../../process-templates/list/api"; +import { processKeys } from "../../shared/queries"; +import { getProcessReview, startProcess } from "../api"; +import type { ProcessStartFormProps } from "../types"; + +export function useProcessStartFormController({ + company, + csrfToken, + onSaved, +}: ProcessStartFormProps) { + const client = useQueryClient(); + const [quick, setQuick] = useState<"person" | "template">(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const query = useQuery({ queryKey: processKeys.catalog(), queryFn: listProcessTemplates }); + const form = useForm({ + resolver: zodResolver(StartProcessInputSchema), + defaultValues: { name: "", personId: "", templateId: "", notes: null }, + }); + const values = form.watch(); + const template = query.data?.templates.find((t) => t.id === values.templateId); + const review = useQuery({ + queryKey: processKeys.review(company.id, values.personId), + queryFn: () => getProcessReview(company.id, values.personId), + enabled: Boolean(values.personId), + }); + const mutation = useMutation({ + mutationFn: (input: StartProcessInput) => startProcess(company.id, input, csrfToken), + onSuccess: async (response) => { + await client.invalidateQueries({ queryKey: processKeys.company(company.id) }); + if (mounted.current) { + onSaved(response.process.id); + } + }, + }); + const onCreated = (id: string) => { + if (mounted.current) { + form.setValue(quick === "person" ? "personId" : "templateId", id); + setQuick(undefined); + } + }; + const submit = form.handleSubmit((v) => mutation.mutate(v)); + return { quick, setQuick, query, form, values, template, review, mutation, onCreated, submit }; +} diff --git a/modules/companies/src/web/processes/start/types/index.ts b/modules/companies/src/web/processes/start/types/index.ts new file mode 100644 index 0000000..d94874c --- /dev/null +++ b/modules/companies/src/web/processes/start/types/index.ts @@ -0,0 +1 @@ +export type { ProcessStartFormProps } from "./process-start-form-props"; diff --git a/modules/companies/src/web/processes/start/types/process-start-form-props.ts b/modules/companies/src/web/processes/start/types/process-start-form-props.ts new file mode 100644 index 0000000..27ea6d7 --- /dev/null +++ b/modules/companies/src/web/processes/start/types/process-start-form-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type ProcessStartFormProps = { + company: CompanyDTO; + csrfToken: string; + onSaved: (id: string) => void; +}; diff --git a/modules/companies/src/web/processes/start/ui/process-start-form.tsx b/modules/companies/src/web/processes/start/ui/process-start-form.tsx index f5bd31f..b64b41a 100644 --- a/modules/companies/src/web/processes/start/ui/process-start-form.tsx +++ b/modules/companies/src/web/processes/start/ui/process-start-form.tsx @@ -1,70 +1,26 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { StartProcessInput } from "../../../../common/processes"; -import { StartProcessInputSchema } from "../../../../common/processes"; import { ProcessQuickCreateDialog } from "../../create"; -import { ProcessFeedback, ProcessHelp, processApi, processKeys } from "../../shared"; -export function ProcessStartForm({ - company, - csrfToken, - onSaved, -}: { - company: CompanyDTO; - csrfToken: string; - onSaved: (id: string) => void; -}) { +import { ProcessFeedback, ProcessHelp } from "../../shared"; +import { useProcessStartFormController } from "../controllers"; +import type { ProcessStartFormProps } from "../types"; + +export function ProcessStartForm(props: ProcessStartFormProps) { + const { company, csrfToken } = props; + const { quick, setQuick, query, form, values, template, review, mutation, onCreated, submit } = + useProcessStartFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const [quick, setQuick] = useState<"person" | "template">(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const query = useQuery({ queryKey: processKeys.catalog(), queryFn: processApi.catalog }); - const form = useForm({ - resolver: zodResolver(StartProcessInputSchema), - defaultValues: { name: "", personId: "", templateId: "", notes: null }, - }); - const values = form.watch(); - const template = query.data?.templates.find((t) => t.id === values.templateId); - const review = useQuery({ - queryKey: processKeys.review(company.id, values.personId), - queryFn: () => processApi.review(company.id, values.personId), - enabled: Boolean(values.personId), - }); - const mutation = useMutation({ - mutationFn: (input: StartProcessInput) => processApi.start(company.id, input, csrfToken), - onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: processKeys.company(company.id) }); - if (mounted.current) { - onSaved(response.process.id); - } - }, - }); - const onCreated = (id: string) => { - if (mounted.current) { - form.setValue(quick === "person" ? "personId" : "templateId", id); - setQuick(undefined); - } - }; return ( <> - mutation.mutate(v))}> +
diff --git a/modules/companies/src/web/processes/update/api/index.ts b/modules/companies/src/web/processes/update/api/index.ts new file mode 100644 index 0000000..1ce24cc --- /dev/null +++ b/modules/companies/src/web/processes/update/api/index.ts @@ -0,0 +1 @@ +export { updateProcess } from "./update-process.api"; diff --git a/modules/companies/src/web/processes/update/api/update-process.api.ts b/modules/companies/src/web/processes/update/api/update-process.api.ts new file mode 100644 index 0000000..8b6ccc6 --- /dev/null +++ b/modules/companies/src/web/processes/update/api/update-process.api.ts @@ -0,0 +1,14 @@ +import type { UpdateProcessInput } from "../../../../common/processes"; +import { ProcessResponseSchema } from "../../../../common/processes"; +import { processRequest } from "../../shared/api/process-request"; + +export async function updateProcess( + company: string, + id: string, + values: UpdateProcessInput, + token: string +) { + return ProcessResponseSchema.parse( + await processRequest(`${company}/${id}/update`, { values }, token) + ); +} diff --git a/modules/companies/src/web/processes/update/controllers/index.ts b/modules/companies/src/web/processes/update/controllers/index.ts new file mode 100644 index 0000000..114a042 --- /dev/null +++ b/modules/companies/src/web/processes/update/controllers/index.ts @@ -0,0 +1 @@ +export { useProcessEditFormController } from "./use-process-edit-form-controller"; diff --git a/modules/companies/src/web/processes/update/controllers/use-process-edit-form-controller.ts b/modules/companies/src/web/processes/update/controllers/use-process-edit-form-controller.ts new file mode 100644 index 0000000..843a02b --- /dev/null +++ b/modules/companies/src/web/processes/update/controllers/use-process-edit-form-controller.ts @@ -0,0 +1,51 @@ +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 { UpdateProcessInput } from "../../../../common/processes"; +import { UpdateProcessInputSchema } from "../../../../common/processes"; +import { processKeys } from "../../shared/queries"; +import { updateProcess } from "../api"; +import type { ProcessEditFormProps } from "../types"; + +export function useProcessEditFormController({ + process, + csrfToken, + onSaved, +}: ProcessEditFormProps) { + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const form = useForm({ + resolver: zodResolver(UpdateProcessInputSchema), + defaultValues: { + name: process.values.name, + state: process.values.state, + notes: process.values.notes, + revisionId: process.history.at(-1)?.id ?? "", + tasks: process.values.tasks.map((task) => ({ + id: task.id, + status: task.status, + notes: task.notes, + })), + }, + }); + const mutation = useMutation({ + mutationFn: (input: UpdateProcessInput) => + updateProcess(process.companyId, process.id, input, csrfToken), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: processKeys.company(process.companyId) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const submit = form.handleSubmit((v) => mutation.mutate(v)); + return { form, mutation, submit }; +} diff --git a/modules/companies/src/web/processes/update/types/index.ts b/modules/companies/src/web/processes/update/types/index.ts new file mode 100644 index 0000000..6d88f5c --- /dev/null +++ b/modules/companies/src/web/processes/update/types/index.ts @@ -0,0 +1 @@ +export type { ProcessEditFormProps } from "./process-edit-form-props"; diff --git a/modules/companies/src/web/processes/update/types/process-edit-form-props.ts b/modules/companies/src/web/processes/update/types/process-edit-form-props.ts new file mode 100644 index 0000000..aba5844 --- /dev/null +++ b/modules/companies/src/web/processes/update/types/process-edit-form-props.ts @@ -0,0 +1,7 @@ +import type { PersonProcessDTO } from "../../../../common/processes"; + +export type ProcessEditFormProps = { + process: PersonProcessDTO; + csrfToken: string; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/processes/update/ui/process-edit-form.tsx b/modules/companies/src/web/processes/update/ui/process-edit-form.tsx index 06dd958..ee8f662 100644 --- a/modules/companies/src/web/processes/update/ui/process-edit-form.tsx +++ b/modules/companies/src/web/processes/update/ui/process-edit-form.tsx @@ -1,65 +1,26 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { PersonProcessDTO, UpdateProcessInput } from "../../../../common/processes"; import { UpdateProcessInputSchema } from "../../../../common/processes"; -import { ProcessFeedback, ProcessHelp, processApi, processKeys } from "../../shared"; +import { ProcessFeedback, ProcessHelp } from "../../shared"; +import { useProcessEditFormController } from "../controllers"; +import type { ProcessEditFormProps } from "../types"; import { ProcessTaskField } from "./process-task-field"; -export function ProcessEditForm({ - process, - csrfToken, - onSaved, -}: { - process: PersonProcessDTO; - csrfToken: string; - onSaved: () => void; -}) { + +export function ProcessEditForm(props: ProcessEditFormProps) { + const { process } = props; + const { form, mutation, submit } = useProcessEditFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const form = useForm({ - resolver: zodResolver(UpdateProcessInputSchema), - defaultValues: { - name: process.values.name, - state: process.values.state, - notes: process.values.notes, - revisionId: process.history.at(-1)?.id ?? "", - tasks: process.values.tasks.map((task) => ({ - id: task.id, - status: task.status, - notes: task.notes, - })), - }, - }); - const mutation = useMutation({ - mutationFn: (input: UpdateProcessInput) => - processApi.update(process.companyId, process.id, input, csrfToken), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: processKeys.company(process.companyId) }); - if (mounted.current) { - onSaved(); - } - }, - }); return ( - mutation.mutate(v))}> +
diff --git a/modules/companies/src/web/processes/update/ui/process-task-field.tsx b/modules/companies/src/web/processes/update/ui/process-task-field.tsx index 286d9bb..dc592bf 100644 --- a/modules/companies/src/web/processes/update/ui/process-task-field.tsx +++ b/modules/companies/src/web/processes/update/ui/process-task-field.tsx @@ -7,7 +7,7 @@ import { useTranslation } from "react-i18next"; import type { PersonProcessDTO, UpdateProcessInput } from "../../../../common/processes"; import { ProcessValuesSchema } from "../../../../common/processes"; -import { processTaskTitle } from "../../shared"; +import { processTaskTitle } from "../../shared/helpers"; export function ProcessTaskField({ index, task, diff --git a/modules/companies/src/web/remote-connections/detail/helpers/index.ts b/modules/companies/src/web/remote-connections/detail/helpers/index.ts new file mode 100644 index 0000000..96d2f8b --- /dev/null +++ b/modules/companies/src/web/remote-connections/detail/helpers/index.ts @@ -0,0 +1 @@ +export { remoteEndpoints } from "./remote-endpoints"; diff --git a/modules/companies/src/web/remote-connections/detail/helpers/remote-endpoints.ts b/modules/companies/src/web/remote-connections/detail/helpers/remote-endpoints.ts new file mode 100644 index 0000000..54ae4d7 --- /dev/null +++ b/modules/companies/src/web/remote-connections/detail/helpers/remote-endpoints.ts @@ -0,0 +1,31 @@ +import type { AssetDTO } from "@metis/assets/common"; +import type { ResourceDTO } from "@metis/resources/common"; + +import type { CompanyDTO } from "../../../../common"; +import type { RemoteConnectionInput } from "../../../../common/remote-connections"; + +export function remoteEndpoints( + values: RemoteConnectionInput, + company: CompanyDTO, + assets: readonly AssetDTO[], + resources: readonly ResourceDTO[], + t: (key: string) => string +) { + const source = + values.sourceKind === "company_asset" + ? (assets.find((item) => item.id === values.sourceAssetId)?.values.name ?? + values.sourceAssetId ?? + "—") + : t(`remote.${values.sourceKind}`); + const target = + values.targetKind === "network" + ? `${t("remote.network")} · ${company.sites.find((site) => site.id === values.siteId)?.name ?? company.name}` + : values.targetKind === "asset" + ? (assets.find((item) => item.id === values.targetId)?.values.name ?? + values.targetId ?? + "—") + : (resources.find((item) => item.id === values.targetId)?.values.name ?? + values.targetId ?? + "—"); + return { source, target }; +} diff --git a/modules/companies/src/web/remote-connections/detail/index.ts b/modules/companies/src/web/remote-connections/detail/index.ts index e73f5ed..3e9ccd0 100644 --- a/modules/companies/src/web/remote-connections/detail/index.ts +++ b/modules/companies/src/web/remote-connections/detail/index.ts @@ -1 +1,2 @@ -export { RemoteSummary, remoteEndpoints } from "./ui"; +export { remoteEndpoints } from "./helpers"; +export { RemoteSummary } from "./ui"; diff --git a/modules/companies/src/web/remote-connections/detail/ui/index.ts b/modules/companies/src/web/remote-connections/detail/ui/index.ts index 9d2237a..59930e7 100644 --- a/modules/companies/src/web/remote-connections/detail/ui/index.ts +++ b/modules/companies/src/web/remote-connections/detail/ui/index.ts @@ -1 +1 @@ -export { RemoteSummary, remoteEndpoints } from "./remote-summary"; +export { RemoteSummary } from "./remote-summary"; diff --git a/modules/companies/src/web/remote-connections/detail/ui/remote-summary.tsx b/modules/companies/src/web/remote-connections/detail/ui/remote-summary.tsx index 390162e..01bbea9 100644 --- a/modules/companies/src/web/remote-connections/detail/ui/remote-summary.tsx +++ b/modules/companies/src/web/remote-connections/detail/ui/remote-summary.tsx @@ -3,36 +3,9 @@ import type { ResourceDTO } from "@metis/resources/common"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; -import type { - RemoteConnectionDTO, - RemoteConnectionInput, -} from "../../../../common/remote-connections"; +import type { RemoteConnectionDTO } from "../../../../common/remote-connections"; +import { remoteEndpoints } from "../helpers"; -export function remoteEndpoints( - values: RemoteConnectionInput, - company: CompanyDTO, - assets: readonly AssetDTO[], - resources: readonly ResourceDTO[], - t: (key: string) => string -) { - const source = - values.sourceKind === "company_asset" - ? (assets.find((item) => item.id === values.sourceAssetId)?.values.name ?? - values.sourceAssetId ?? - "—") - : t(`remote.${values.sourceKind}`); - const target = - values.targetKind === "network" - ? `${t("remote.network")} · ${company.sites.find((site) => site.id === values.siteId)?.name ?? company.name}` - : values.targetKind === "asset" - ? (assets.find((item) => item.id === values.targetId)?.values.name ?? - values.targetId ?? - "—") - : (resources.find((item) => item.id === values.targetId)?.values.name ?? - values.targetId ?? - "—"); - return { source, target }; -} export function RemoteSummary({ connection, company, diff --git a/modules/companies/src/web/remote-connections/history/ui/remote-connection-history-dialog.tsx b/modules/companies/src/web/remote-connections/history/ui/remote-connection-history-dialog.tsx index 9792ef9..1772136 100644 --- a/modules/companies/src/web/remote-connections/history/ui/remote-connection-history-dialog.tsx +++ b/modules/companies/src/web/remote-connections/history/ui/remote-connection-history-dialog.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; import type { RemoteConnectionDTO } from "../../../../common/remote-connections"; -import { remoteEndpoints } from "../../detail"; +import { remoteEndpoints } from "../../detail/helpers"; export function RemoteConnectionHistoryDialog({ connection, diff --git a/modules/companies/src/web/remote-connections/index.ts b/modules/companies/src/web/remote-connections/index.ts index 0961cb5..f245566 100644 --- a/modules/companies/src/web/remote-connections/index.ts +++ b/modules/companies/src/web/remote-connections/index.ts @@ -1,3 +1,3 @@ export { RemoteSummary } from "./detail"; export { RemoteConnectionsPage } from "./list"; -export { remoteApi, remoteKeys } from "./shared"; +export { remoteApi, remoteKeys } from "./shared/api"; diff --git a/modules/companies/src/web/remote-connections/list/controllers/index.ts b/modules/companies/src/web/remote-connections/list/controllers/index.ts new file mode 100644 index 0000000..54bd8d3 --- /dev/null +++ b/modules/companies/src/web/remote-connections/list/controllers/index.ts @@ -0,0 +1 @@ +export { useRemoteConnectionsPageController } from "./use-remote-connections-page-controller"; diff --git a/modules/companies/src/web/remote-connections/list/controllers/use-remote-connections-page-controller.ts b/modules/companies/src/web/remote-connections/list/controllers/use-remote-connections-page-controller.ts new file mode 100644 index 0000000..2dda5c3 --- /dev/null +++ b/modules/companies/src/web/remote-connections/list/controllers/use-remote-connections-page-controller.ts @@ -0,0 +1,75 @@ +import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client"; +import { + inventoryApi as resourceApi, + inventoryKeys as resourceKeys, +} from "@metis/resources/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { remoteEndpoints } from "../../detail/helpers"; +import { remoteApi, remoteKeys } from "../../shared/api"; +import type { RemoteConnectionsPageProps } from "../types"; + +export function useRemoteConnectionsPageController({ + company, + initialPersonId, +}: RemoteConnectionsPageProps) { + const { t } = useTranslation("companies"); + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const [closed, setClosed] = useState(false); + const query = useQuery({ + queryKey: remoteKeys.company(company.id), + queryFn: () => remoteApi.get(company.id), + }); + const assets = useQuery({ + queryKey: assetKeys.company(company.id), + queryFn: () => assetApi.get(company.id), + }); + const resources = useQuery({ + queryKey: resourceKeys.company(company.id), + queryFn: () => resourceApi.get(company.id), + }); + const connections = query.data?.connections ?? []; + const edited = connections.find((c) => c.id === editor); + const viewed = connections.find((c) => c.id === view); + const person = (id: string) => company.people.find((p) => p.id === id)?.name ?? id; + const inventory = assets.data?.assets ?? []; + const entries = resources.data?.resources ?? []; + const isClosed = (state: string) => state === "removed" || state === "never_configured"; + const filtered = connections.filter((c) => { + const route = remoteEndpoints(c.values, company, inventory, entries, t); + return ( + (!initialPersonId || c.values.personId === initialPersonId) && + (closed || !isClosed(c.values.state)) && + [person(c.values.personId), route.source, route.target, c.values.method].some((text) => + text.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ) + ); + }); + const error = query.error ?? assets.error ?? resources.error; + const ready = Boolean(query.data && assets.data && resources.data && !error); + return { + editor, + setEditor, + setView, + search, + setSearch, + closed, + setClosed, + query, + assets, + resources, + edited, + viewed, + person, + inventory, + entries, + isClosed, + filtered, + error, + ready, + }; +} diff --git a/modules/companies/src/web/remote-connections/list/types/index.ts b/modules/companies/src/web/remote-connections/list/types/index.ts new file mode 100644 index 0000000..dbc99d6 --- /dev/null +++ b/modules/companies/src/web/remote-connections/list/types/index.ts @@ -0,0 +1 @@ +export type { RemoteConnectionsPageProps } from "./remote-connections-page-props"; diff --git a/modules/companies/src/web/remote-connections/list/types/remote-connections-page-props.ts b/modules/companies/src/web/remote-connections/list/types/remote-connections-page-props.ts new file mode 100644 index 0000000..c6b87ae --- /dev/null +++ b/modules/companies/src/web/remote-connections/list/types/remote-connections-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type RemoteConnectionsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialPersonId?: string | undefined; +}; diff --git a/modules/companies/src/web/remote-connections/list/ui/remote-connections-page.tsx b/modules/companies/src/web/remote-connections/list/ui/remote-connections-page.tsx index e04320d..3f67eda 100644 --- a/modules/companies/src/web/remote-connections/list/ui/remote-connections-page.tsx +++ b/modules/companies/src/web/remote-connections/list/ui/remote-connections-page.tsx @@ -1,8 +1,3 @@ -import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client"; -import { - inventoryApi as resourceApi, - inventoryKeys as resourceKeys, -} from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -11,60 +6,38 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import { RemoteSummary, remoteEndpoints } from "../../detail"; +import { RemoteSummary } from "../../detail"; import { RemoteConnectionHistoryDialog } from "../../history"; -import { RemoteConnectionEditorDialog, RemoteFeedback, remoteApi, remoteKeys } from "../../shared"; +import { RemoteConnectionEditorDialog, RemoteFeedback } from "../../shared"; +import { useRemoteConnectionsPageController } from "../controllers"; +import type { RemoteConnectionsPageProps } from "../types"; -export function RemoteConnectionsPage({ - company, - csrfToken, - initialPersonId, -}: { - company: CompanyDTO; - csrfToken: string; - initialPersonId?: string | undefined; -}) { +export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) { + const { company, csrfToken, initialPersonId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + closed, + setClosed, + query, + assets, + resources, + edited, + viewed, + person, + inventory, + entries, + isClosed, + filtered, + error, + ready, + } = useRemoteConnectionsPageController(props); const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const [closed, setClosed] = useState(false); - const query = useQuery({ - queryKey: remoteKeys.company(company.id), - queryFn: () => remoteApi.get(company.id), - }); - const assets = useQuery({ - queryKey: assetKeys.company(company.id), - queryFn: () => assetApi.get(company.id), - }); - const resources = useQuery({ - queryKey: resourceKeys.company(company.id), - queryFn: () => resourceApi.get(company.id), - }); - const connections = query.data?.connections ?? []; - const edited = connections.find((c) => c.id === editor); - const viewed = connections.find((c) => c.id === view); - const person = (id: string) => company.people.find((p) => p.id === id)?.name ?? id; - const inventory = assets.data?.assets ?? []; - const entries = resources.data?.resources ?? []; - const isClosed = (state: string) => state === "removed" || state === "never_configured"; - const filtered = connections.filter((c) => { - const route = remoteEndpoints(c.values, company, inventory, entries, t); - return ( - (!initialPersonId || c.values.personId === initialPersonId) && - (closed || !isClosed(c.values.state)) && - [person(c.values.personId), route.source, route.target, c.values.method].some((text) => - text.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ) - ); - }); - const error = query.error ?? assets.error ?? resources.error; - const ready = Boolean(query.data && assets.data && resources.data && !error); return (
; +export function useRemoteConnectionFormController({ + company, + csrfToken, + connection, + initialPersonId, + onSaved, +}: RemoteConnectionFormProps) { + const client = useQueryClient(); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState(); + const assets = useQuery({ + queryKey: assetKeys.company(company.id), + queryFn: () => assetApi.get(company.id), + }); + const resources = useQuery({ + queryKey: resourceKeys.company(company.id), + queryFn: () => resourceApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(RemoteConnectionInputSchema), + defaultValues: connection?.values ?? { + personId: initialPersonId ?? "", + sourceKind: "company_asset", + sourceAssetId: null, + targetKind: "network", + targetId: null, + siteId: null, + method: "", + requiresVpn: false, + state: "pending", + notes: null, + }, + }); + const v = form.watch(); + const fixed = Boolean(connection); + const personFixed = Boolean(connection || initialPersonId); + const ready = Boolean(assets.data && resources.data && !assets.isError && !resources.isError); + const states: readonly RemoteConnectionInput["state"][] = connection + ? REMOTE_STATE_TRANSITIONS[connection.values.state] + : ["pending", "configured"]; + const mutation = useMutation({ + mutationFn: (values: RemoteConnectionInput) => + remoteApi.save(company.id, values, csrfToken, connection?.id), + onSuccess: async () => { + await client.invalidateQueries({ queryKey: remoteKeys.company(company.id) }); + if (mounted.current) { + onSaved(); + } + }, + }); + const assetOptions = (assets.data?.assets ?? []).map((a) => ({ + value: a.id, + label: a.values.name, + })); + const resourceOptions = (resources.data?.resources ?? []).map((r) => ({ + value: r.id, + label: r.values.name, + })); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + const quickCreated = (id: string) => { + if (mounted.current && quick) { + form.setValue(QUICK_FIELDS[quick], id); + setQuick(undefined); + } + }; + return { + quick, + setQuick, + assets, + resources, + form, + v, + fixed, + personFixed, + ready, + states, + mutation, + assetOptions, + resourceOptions, + submit, + quickCreated, + }; +} diff --git a/modules/companies/src/web/remote-connections/shared/types/index.ts b/modules/companies/src/web/remote-connections/shared/types/index.ts new file mode 100644 index 0000000..8047d2a --- /dev/null +++ b/modules/companies/src/web/remote-connections/shared/types/index.ts @@ -0,0 +1 @@ +export type { RemoteConnectionFormProps } from "./remote-connection-form-props"; diff --git a/modules/companies/src/web/remote-connections/shared/types/remote-connection-form-props.ts b/modules/companies/src/web/remote-connections/shared/types/remote-connection-form-props.ts new file mode 100644 index 0000000..69366d9 --- /dev/null +++ b/modules/companies/src/web/remote-connections/shared/types/remote-connection-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { RemoteConnectionDTO } from "../../../../common/remote-connections"; + +export type RemoteConnectionFormProps = { + company: CompanyDTO; + csrfToken: string; + connection?: RemoteConnectionDTO | undefined; + initialPersonId?: string | undefined; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/remote-connections/shared/ui/remote-connection-form.tsx b/modules/companies/src/web/remote-connections/shared/ui/remote-connection-form.tsx index c15c710..44ba0e5 100644 --- a/modules/companies/src/web/remote-connections/shared/ui/remote-connection-form.tsx +++ b/modules/companies/src/web/remote-connections/shared/ui/remote-connection-form.tsx @@ -1,9 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { inventoryApi as assetApi, inventoryKeys as assetKeys } from "@metis/assets/client"; -import { - inventoryApi as resourceApi, - inventoryKeys as resourceKeys, -} from "@metis/resources/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; @@ -12,110 +6,41 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { - RemoteConnectionDTO, - RemoteConnectionInput, -} from "../../../../common/remote-connections"; -import { - REMOTE_STATE_TRANSITIONS, - RemoteConnectionInputSchema, -} from "../../../../common/remote-connections"; -import type { RemoteQuickKind } from "../../create"; +import { RemoteConnectionInputSchema } from "../../../../common/remote-connections"; import { RemoteQuickEditor } from "../../create"; -import { remoteApi, remoteKeys } from "../api"; +import { useRemoteConnectionFormController } from "../controllers"; +import type { RemoteConnectionFormProps } from "../types"; import { RemoteEndpointFields } from "./remote-endpoint-fields"; import { RemoteFeedback } from "./remote-feedback"; -const quickFields = { - person: "personId", - source: "sourceAssetId", - target: "targetId", - resource: "targetId", - site: "siteId", -} satisfies Record; - -export function RemoteConnectionForm({ - company, - csrfToken, - connection, - initialPersonId, - onSaved, -}: { - company: CompanyDTO; - csrfToken: string; - connection?: RemoteConnectionDTO | undefined; - initialPersonId?: string | undefined; - onSaved: () => void; -}) { +export function RemoteConnectionForm(props: RemoteConnectionFormProps) { + const { company, csrfToken } = props; + const { + quick, + setQuick, + assets, + resources, + form, + v, + fixed, + personFixed, + ready, + states, + mutation, + assetOptions, + resourceOptions, + submit, + quickCreated, + } = useRemoteConnectionFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState(); - const assets = useQuery({ - queryKey: assetKeys.company(company.id), - queryFn: () => assetApi.get(company.id), - }); - const resources = useQuery({ - queryKey: resourceKeys.company(company.id), - queryFn: () => resourceApi.get(company.id), - }); - const form = useForm({ - resolver: zodResolver(RemoteConnectionInputSchema), - defaultValues: connection?.values ?? { - personId: initialPersonId ?? "", - sourceKind: "company_asset", - sourceAssetId: null, - targetKind: "network", - targetId: null, - siteId: null, - method: "", - requiresVpn: false, - state: "pending", - notes: null, - }, - }); - const v = form.watch(); - const fixed = Boolean(connection); - const personFixed = Boolean(connection || initialPersonId); - const ready = Boolean(assets.data && resources.data && !assets.isError && !resources.isError); - const states: readonly RemoteConnectionInput["state"][] = connection - ? REMOTE_STATE_TRANSITIONS[connection.values.state] - : ["pending", "configured"]; - const mutation = useMutation({ - mutationFn: (values: RemoteConnectionInput) => - remoteApi.save(company.id, values, csrfToken, connection?.id), - onSuccess: async () => { - await client.invalidateQueries({ queryKey: remoteKeys.company(company.id) }); - if (mounted.current) { - onSaved(); - } - }, - }); - const assetOptions = (assets.data?.assets ?? []).map((a) => ({ - value: a.id, - label: a.values.name, - })); - const resourceOptions = (resources.data?.resources ?? []).map((r) => ({ - value: r.id, - label: r.values.name, - })); return ( <> - mutation.mutate(values))}> +
@@ -224,12 +149,7 @@ export function RemoteConnectionForm({ csrfToken={csrfToken} kind={quick} onClose={() => setQuick(undefined)} - onSaved={(id) => { - if (mounted.current) { - form.setValue(quickFields[quick], id); - setQuick(undefined); - } - }} + onSaved={quickCreated} personId={v.personId} /> )} diff --git a/modules/companies/src/web/shared/client-contracts.test.ts b/modules/companies/src/web/shared/client-contracts.test.ts new file mode 100644 index 0000000..9ba8769 --- /dev/null +++ b/modules/companies/src/web/shared/client-contracts.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { COMPANY_LOGO_LIMITS, COMPANY_LOGO_TYPES } from "../../common"; +import { getAccess } from "../access/list/api"; +import { saveAccess } from "../access/shared/api"; +import { validateCompanyLogo } from "../companies/change-logo/helpers"; +import { getCompany } from "../companies/detail/api"; +import { listCompanies } from "../companies/list/api"; +import { saveMailboxUse } from "../mailbox-uses/shared/api"; +import { getMailboxes } from "../mailboxes/list/api"; +import { buildPersonRequest } from "../people/shared/adapters"; +import { buildProcessTemplateInput } from "../process-templates/shared/adapters"; +import { buildSiteRequest } from "../sites/shared/adapters"; + +const COMPANY_ID = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4"; + +test("las consultas de compañías conservan sus rutas y rechazan respuestas inválidas", async (context) => { + context.mock.method(globalThis, "fetch", async (url: unknown, options: RequestInit) => { + assert.equal(options.method, "GET"); + if (url === "/api/companies") { + return Response.json({ companies: [] }); + } + assert.equal(url, `/api/companies/${COMPANY_ID}`); + return Response.json({ company: { id: COMPANY_ID } }); + }); + assert.deepEqual(await listCompanies(), { companies: [] }); + await assert.rejects(getCompany(COMPANY_ID)); +}); + +test("lectura y guardado de accesos están separados sin cambiar el contrato HTTP", async (context) => { + const calls: { url: unknown; options: RequestInit }[] = []; + context.mock.method(globalThis, "fetch", async (url: unknown, options: RequestInit) => { + calls.push({ url, options }); + return Response.json({ items: [], reviews: [], coverage: [] }); + }); + await getAccess("global"); + const values = { kind: "system", name: "Directory", active: true } as const; + await saveAccess("global", values, COMPANY_ID, "csrf"); + assert.deepEqual( + calls.map((call) => call.url), + ["/api/access/global", `/api/access/global/${COMPANY_ID}/update`] + ); + assert.equal(calls[0]?.options.method, "GET"); + const save = calls[1]?.options; + assert.ok(save); + assert.equal(save.method, "POST"); + assert.equal(save.body, JSON.stringify({ values })); + assert.equal(new Headers(save.headers).get("x-csrf-token"), "csrf"); +}); + +test("el guardado de usos de buzón mantiene su endpoint y no se confunde con la consulta", async (context) => { + const calls: { url: unknown; options: RequestInit }[] = []; + context.mock.method(globalThis, "fetch", async (url: unknown, options: RequestInit) => { + calls.push({ url, options }); + return Response.json({ mailboxes: [], uses: [] }); + }); + await getMailboxes(COMPANY_ID); + const values = { + mailboxId: COMPANY_ID, + personId: COMPANY_ID, + startDate: "2026-10-09", + endDate: null, + }; + await saveMailboxUse(COMPANY_ID, values, "csrf", COMPANY_ID); + assert.deepEqual( + calls.map((call) => call.url), + [`/api/mailboxes/${COMPANY_ID}`, `/api/mailboxes/${COMPANY_ID}/uses/${COMPANY_ID}/update`] + ); + assert.equal(calls[0]?.options.method, "GET"); + assert.equal(calls[1]?.options.method, "POST"); + assert.equal(calls[1]?.options.body, JSON.stringify({ values })); +}); + +test("los adapters mantienen la ausencia semántica sin modificar los campos del formulario", () => { + const person = { + name: "Ana", + position: " ", + email: " ana@example.test ", + phone: "", + active: false, + isContact: true, + }; + const request = buildPersonRequest(person); + assert.equal(request.position, null); + assert.equal(request.email, "ana@example.test"); + assert.equal(request.phone, null); + assert.equal(request.active, false); + assert.equal(person.email, " ana@example.test "); + assert.deepEqual(buildSiteRequest({ name: "Office", address: " " }), { + name: "Office", + address: null, + }); + assert.equal( + buildSiteRequest({ name: "Office", address: " Street\n Floor " }).address, + " Street\n Floor " + ); +}); + +test("las instrucciones de proceso conservan el orden y excluyen líneas vacías", () => { + const values = { + name: "Starter", + kind: "onboarding", + active: true, + notes: null, + lines: " First\n\n Second \r\n ", + } as const; + assert.deepEqual(buildProcessTemplateInput(values).instructions, ["First", "Second"]); + assert.equal(values.lines, " First\n\n Second \r\n "); + assert.deepEqual(buildProcessTemplateInput({ ...values, lines: "\n " }).instructions, []); +}); + +test("la validación de logos conserva tipos permitidos, vacío y límite exacto de tamaño", () => { + for (const type of COMPANY_LOGO_TYPES) { + assert.equal(validateCompanyLogo({ type, size: COMPANY_LOGO_LIMITS.inputBytes }), null); + assert.equal(validateCompanyLogo({ type, size: 0 }), "LOGO_TOO_LARGE"); + assert.equal( + validateCompanyLogo({ type, size: COMPANY_LOGO_LIMITS.inputBytes + 1 }), + "LOGO_TOO_LARGE" + ); + } + assert.equal(validateCompanyLogo({ type: "image/svg+xml", size: 1 }), "INVALID_LOGO"); +}); diff --git a/modules/companies/src/web/shared/controllers/index.ts b/modules/companies/src/web/shared/controllers/index.ts new file mode 100644 index 0000000..7843d0a --- /dev/null +++ b/modules/companies/src/web/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useConfigurationQuickEditorController } from "./use-configuration-quick-editor-controller"; diff --git a/modules/companies/src/web/shared/controllers/use-configuration-quick-editor-controller.ts b/modules/companies/src/web/shared/controllers/use-configuration-quick-editor-controller.ts new file mode 100644 index 0000000..de02aee --- /dev/null +++ b/modules/companies/src/web/shared/controllers/use-configuration-quick-editor-controller.ts @@ -0,0 +1,35 @@ +import { catalogApi, catalogKeys } from "@metis/assets/client"; +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; + +import type { ConfigurationQuickEditorProps } from "../types"; + +export function useConfigurationQuickEditorController({ + kind, + company, + onCreated, + onClose, +}: ConfigurationQuickEditorProps) { + const active = useRef(true); + useEffect(() => { + active.current = true; + return () => { + active.current = false; + }; + }, []); + const created = (field: "personId" | "accountId" | "assetId", id?: string) => { + if (active.current) { + onCreated(field, id); + } + }; + const close = () => { + active.current = false; + onClose(); + }; + const catalog = useQuery({ + queryKey: catalogKeys.scope(company.id), + queryFn: () => catalogApi.get(company.id), + enabled: kind === "asset", + }); + return { created, close, catalog }; +} diff --git a/modules/companies/src/web/shared/index.ts b/modules/companies/src/web/shared/index.ts index 5755efa..f925b3e 100644 --- a/modules/companies/src/web/shared/index.ts +++ b/modules/companies/src/web/shared/index.ts @@ -1,3 +1,3 @@ export { companiesEnglish, companiesSpanish } from "./i18n"; -export type { ConfigurationQuickKind } from "./ui"; +export type { ConfigurationQuickKind } from "./types"; export { ConfigurationQuickEditor, InitialsAvatar } from "./ui"; diff --git a/modules/companies/src/web/shared/types/configuration-quick-editor-props.ts b/modules/companies/src/web/shared/types/configuration-quick-editor-props.ts new file mode 100644 index 0000000..89d0c5f --- /dev/null +++ b/modules/companies/src/web/shared/types/configuration-quick-editor-props.ts @@ -0,0 +1,17 @@ +import type { AccessResponseDTO, CompanyDTO } from "../../../common"; + +export type ConfigurationQuickKind = "person" | "account" | "asset" | "authorization"; + +export type ConfigurationQuickEditorProps = { + kind: ConfigurationQuickKind; + title?: string; + company: CompanyDTO; + csrfToken: string; + values: Readonly<{ + personId: string; + accountId: string; + }>; + data: AccessResponseDTO | undefined; + onCreated: (field: "personId" | "accountId" | "assetId", id?: string) => void; + onClose: () => void; +}; diff --git a/modules/companies/src/web/shared/types/index.ts b/modules/companies/src/web/shared/types/index.ts new file mode 100644 index 0000000..b45fd5e --- /dev/null +++ b/modules/companies/src/web/shared/types/index.ts @@ -0,0 +1,4 @@ +export type { + ConfigurationQuickEditorProps, + ConfigurationQuickKind, +} from "./configuration-quick-editor-props"; diff --git a/modules/companies/src/web/shared/ui/configuration-quick-editor.tsx b/modules/companies/src/web/shared/ui/configuration-quick-editor.tsx index 3bb26c7..ac3cc7e 100644 --- a/modules/companies/src/web/shared/ui/configuration-quick-editor.tsx +++ b/modules/companies/src/web/shared/ui/configuration-quick-editor.tsx @@ -1,14 +1,12 @@ -import { AssetForm, catalogApi, catalogKeys } from "@metis/assets/client"; +import { AssetForm } from "@metis/assets/client"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { useQuery } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import type { AccessResponseDTO, CompanyDTO } from "../../../common"; import { AccessForm } from "../../access"; import { MailFeedback } from "../../mail-configurations/shared/ui/mail-feedback"; import { PersonForm } from "../../people"; -export type ConfigurationQuickKind = "person" | "account" | "asset" | "authorization"; +import { useConfigurationQuickEditorController } from "../controllers"; +import type { ConfigurationQuickEditorProps } from "../types"; const titles = { person: "newPerson", @@ -16,47 +14,10 @@ const titles = { asset: "newAsset", authorization: "authorize", } as const; -export function ConfigurationQuickEditor({ - kind, - title, - company, - csrfToken, - values, - data, - onCreated, - onClose, -}: { - kind: ConfigurationQuickKind; - title?: string; - company: CompanyDTO; - csrfToken: string; - values: Readonly<{ personId: string; accountId: string }>; - data: AccessResponseDTO | undefined; - onCreated: (field: "personId" | "accountId" | "assetId", id?: string) => void; - onClose: () => void; -}) { +export function ConfigurationQuickEditor(props: ConfigurationQuickEditorProps) { + const { kind, title, company, csrfToken, values, data } = props; + const { created, close, catalog } = useConfigurationQuickEditorController(props); const { t } = useTranslation("companies"); - const active = useRef(true); - useEffect(() => { - active.current = true; - return () => { - active.current = false; - }; - }, []); - const created = (field: "personId" | "accountId" | "assetId", id?: string) => { - if (active.current) { - onCreated(field, id); - } - }; - const close = () => { - active.current = false; - onClose(); - }; - const catalog = useQuery({ - queryKey: catalogKeys.scope(company.id), - queryFn: () => catalogApi.get(company.id), - enabled: kind === "asset", - }); return ( + companyRequest( + `/${companyId}/sites${id ? `/${id}/update` : ""}`, + CompanyResponseSchema, + body, + token + ); diff --git a/modules/companies/src/web/sites/shared/controllers/index.ts b/modules/companies/src/web/sites/shared/controllers/index.ts new file mode 100644 index 0000000..dd94c16 --- /dev/null +++ b/modules/companies/src/web/sites/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useSiteFormController } from "./use-site-form-controller"; diff --git a/modules/companies/src/web/sites/shared/controllers/use-site-form-controller.ts b/modules/companies/src/web/sites/shared/controllers/use-site-form-controller.ts new file mode 100644 index 0000000..57b1525 --- /dev/null +++ b/modules/companies/src/web/sites/shared/controllers/use-site-form-controller.ts @@ -0,0 +1,36 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; + +import type { SiteRequestDTO } from "../../../../common"; +import { SiteRequestSchema } from "../../../../common"; +import { companyKeys } from "../../../companies/shared/queries"; +import { buildSiteRequest } from "../adapters"; +import { saveSite } from "../api"; +import type { SiteFormProps } from "../types"; + +export function useSiteFormController({ site, companyId, csrfToken, onSaved }: SiteFormProps) { + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver(SiteRequestSchema), + defaultValues: { name: site?.name ?? "", address: site?.address ?? "" }, + }); + const mutation = useMutation({ + mutationFn: (values: SiteRequestDTO) => + saveSite(companyId, site?.id, buildSiteRequest(values), csrfToken), + onSuccess: (response) => { + client.setQueryData(companyKeys.detail(companyId), response); + if (!site) { + form.reset(); + } + const saved = site + ? response.company.sites.find((item) => item.id === site.id) + : response.company.sites.at(-1); + if (saved) { + onSaved?.(saved.id); + } + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/companies/src/web/sites/shared/types/index.ts b/modules/companies/src/web/sites/shared/types/index.ts new file mode 100644 index 0000000..7f0ad8a --- /dev/null +++ b/modules/companies/src/web/sites/shared/types/index.ts @@ -0,0 +1 @@ +export type { SiteFormProps } from "./site-form-props"; diff --git a/modules/companies/src/web/sites/shared/types/site-form-props.ts b/modules/companies/src/web/sites/shared/types/site-form-props.ts new file mode 100644 index 0000000..9721aa0 --- /dev/null +++ b/modules/companies/src/web/sites/shared/types/site-form-props.ts @@ -0,0 +1,9 @@ +import type { SiteDTO } from "../../../../common"; + +export type SiteFormProps = { + site?: SiteDTO; + companyId: string; + csrfToken: string; + onSaved?: (id: string) => void; + showTitle?: boolean; +}; diff --git a/modules/companies/src/web/sites/shared/ui/site-form.tsx b/modules/companies/src/web/sites/shared/ui/site-form.tsx index 555de98..c147595 100644 --- a/modules/companies/src/web/sites/shared/ui/site-form.tsx +++ b/modules/companies/src/web/sites/shared/ui/site-form.tsx @@ -1,62 +1,23 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { SiteDTO, SiteRequestDTO } from "../../../../common"; -import { SiteRequestSchema } from "../../../../common"; import { EntityHelp } from "../../../access"; -import { CompanyFeedback, companyApi, companyKeys } from "../../../companies/shared"; +import { CompanyFeedback } from "../../../companies/shared"; +import { useSiteFormController } from "../controllers"; +import type { SiteFormProps } from "../types"; -export function SiteForm({ - site, - companyId, - csrfToken, - onSaved, - showTitle = true, -}: { - site?: SiteDTO; - companyId: string; - csrfToken: string; - onSaved?: (id: string) => void; - showTitle?: boolean; -}) { +export function SiteForm(props: SiteFormProps) { + const { site, showTitle = true } = props; + const { form, mutation, submit } = useSiteFormController(props); const { t } = useTranslation("companies"); const id = useId(); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver(SiteRequestSchema), - defaultValues: { name: site?.name ?? "", address: site?.address ?? "" }, - }); - const mutation = useMutation({ - mutationFn: (values: SiteRequestDTO) => - companyApi.site( - companyId, - site?.id, - { ...values, address: values.address?.trim() ? values.address : null }, - csrfToken - ), - onSuccess: (response) => { - client.setQueryData(companyKeys.detail(companyId), response); - if (!site) { - form.reset(); - } - const saved = site - ? response.company.sites.find((item) => item.id === site.id) - : response.company.sites.at(-1); - if (saved) { - onSaved?.(saved.id); - } - }, - }); return ( - mutation.mutate(values))}> + {showTitle &&

{t(site ? "editSite" : "newSite")}

}
diff --git a/modules/companies/src/web/telework-authorizations/index.ts b/modules/companies/src/web/telework-authorizations/index.ts index ea40923..72ebe2b 100644 --- a/modules/companies/src/web/telework-authorizations/index.ts +++ b/modules/companies/src/web/telework-authorizations/index.ts @@ -1,3 +1,3 @@ export { TeleworkAuthorizationSummary } from "./detail"; export { TeleworkAuthorizationsPage } from "./list"; -export { teleworkApi, teleworkKeys } from "./shared"; +export { teleworkApi, teleworkKeys } from "./shared/api"; diff --git a/modules/companies/src/web/telework-authorizations/list/controllers/index.ts b/modules/companies/src/web/telework-authorizations/list/controllers/index.ts new file mode 100644 index 0000000..6400f94 --- /dev/null +++ b/modules/companies/src/web/telework-authorizations/list/controllers/index.ts @@ -0,0 +1 @@ +export { useTeleworkAuthorizationsPageController } from "./use-telework-authorizations-page-controller"; diff --git a/modules/companies/src/web/telework-authorizations/list/controllers/use-telework-authorizations-page-controller.ts b/modules/companies/src/web/telework-authorizations/list/controllers/use-telework-authorizations-page-controller.ts new file mode 100644 index 0000000..8b3fc23 --- /dev/null +++ b/modules/companies/src/web/telework-authorizations/list/controllers/use-telework-authorizations-page-controller.ts @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { teleworkApi, teleworkKeys } from "../../shared/api"; +import type { TeleworkAuthorizationsPageProps } from "../types"; + +export function useTeleworkAuthorizationsPageController({ + company, + initialPersonId, +}: TeleworkAuthorizationsPageProps) { + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const query = useQuery({ + queryKey: teleworkKeys.company(company.id), + queryFn: () => teleworkApi.get(company.id), + }); + const authorizations = query.data?.authorizations ?? []; + const edited = authorizations.find((item) => item.id === editor); + const viewed = authorizations.find((item) => item.id === view); + const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; + const filtered = authorizations.filter( + (item) => + (!initialPersonId || item.values.personId === initialPersonId) && + person(item.values.personId).toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ); + return { + editor, + setEditor, + setView, + search, + setSearch, + query, + authorizations, + edited, + viewed, + person, + filtered, + }; +} diff --git a/modules/companies/src/web/telework-authorizations/list/types/index.ts b/modules/companies/src/web/telework-authorizations/list/types/index.ts new file mode 100644 index 0000000..753e399 --- /dev/null +++ b/modules/companies/src/web/telework-authorizations/list/types/index.ts @@ -0,0 +1 @@ +export type { TeleworkAuthorizationsPageProps } from "./telework-authorizations-page-props"; diff --git a/modules/companies/src/web/telework-authorizations/list/types/telework-authorizations-page-props.ts b/modules/companies/src/web/telework-authorizations/list/types/telework-authorizations-page-props.ts new file mode 100644 index 0000000..20644cd --- /dev/null +++ b/modules/companies/src/web/telework-authorizations/list/types/telework-authorizations-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type TeleworkAuthorizationsPageProps = { + company: CompanyDTO; + csrfToken: string; + initialPersonId?: string | undefined; +}; diff --git a/modules/companies/src/web/telework-authorizations/list/ui/telework-authorizations-page.tsx b/modules/companies/src/web/telework-authorizations/list/ui/telework-authorizations-page.tsx index 3a2d047..db3003e 100644 --- a/modules/companies/src/web/telework-authorizations/list/ui/telework-authorizations-page.tsx +++ b/modules/companies/src/web/telework-authorizations/list/ui/telework-authorizations-page.tsx @@ -6,46 +6,30 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; import { TeleworkAuthorizationSummary } from "../../detail"; import { TeleworkAuthorizationHistoryDialog } from "../../history"; -import { - TeleworkAuthorizationEditorDialog, - TeleworkFeedback, - teleworkApi, - teleworkKeys, -} from "../../shared"; +import { TeleworkAuthorizationEditorDialog, TeleworkFeedback } from "../../shared"; +import { useTeleworkAuthorizationsPageController } from "../controllers"; +import type { TeleworkAuthorizationsPageProps } from "../types"; -export function TeleworkAuthorizationsPage({ - company, - csrfToken, - initialPersonId, -}: { - company: CompanyDTO; - csrfToken: string; - initialPersonId?: string | undefined; -}) { +export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProps) { + const { company, csrfToken, initialPersonId } = props; + const { + editor, + setEditor, + setView, + search, + setSearch, + query, + authorizations, + edited, + viewed, + person, + filtered, + } = useTeleworkAuthorizationsPageController(props); const { t, i18n } = useTranslation("companies"); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: teleworkKeys.company(company.id), - queryFn: () => teleworkApi.get(company.id), - }); - const authorizations = query.data?.authorizations ?? []; - const edited = authorizations.find((item) => item.id === editor); - const viewed = authorizations.find((item) => item.id === view); - const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; - const filtered = authorizations.filter( - (item) => - (!initialPersonId || item.values.personId === initialPersonId) && - person(item.values.personId).toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ); return (
{ + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState(false); + const form = useForm({ + resolver: zodResolver(TeleworkAuthorizationInputSchema), + defaultValues: authorization?.values ?? { + personId: initialPersonId ?? "", + active: true, + startsOn: null, + expiresOn: null, + notes: null, + }, + }); + const values = form.watch(); + const fixed = Boolean(authorization || initialPersonId); + const mutation = useMutation({ + mutationFn: (input: TeleworkAuthorizationInput) => + teleworkApi.save(company.id, input, csrfToken, authorization?.id), + onSuccess: async () => { + await Promise.all([ + client.invalidateQueries({ queryKey: teleworkKeys.company(company.id) }), + client.invalidateQueries({ queryKey: remoteKeys.company(company.id) }), + ]); + if (mounted.current) { + onSaved(); + } + }, + }); + const reserved = new Set(authorizations.map((item) => item.values.personId)); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const personCreated = (id: string) => { + if (mounted.current) { + form.setValue("personId", id); + setQuick(false); + } + }; + return { quick, setQuick, form, values, fixed, mutation, reserved, submit, personCreated }; +} diff --git a/modules/companies/src/web/telework-authorizations/shared/types/index.ts b/modules/companies/src/web/telework-authorizations/shared/types/index.ts new file mode 100644 index 0000000..816892d --- /dev/null +++ b/modules/companies/src/web/telework-authorizations/shared/types/index.ts @@ -0,0 +1 @@ +export type { TeleworkAuthorizationFormProps } from "./telework-authorization-form-props"; diff --git a/modules/companies/src/web/telework-authorizations/shared/types/telework-authorization-form-props.ts b/modules/companies/src/web/telework-authorizations/shared/types/telework-authorization-form-props.ts new file mode 100644 index 0000000..2b530ce --- /dev/null +++ b/modules/companies/src/web/telework-authorizations/shared/types/telework-authorization-form-props.ts @@ -0,0 +1,11 @@ +import type { CompanyDTO } from "../../../../common"; +import type { TeleworkAuthorizationDTO } from "../../../../common/telework-authorizations"; + +export type TeleworkAuthorizationFormProps = { + company: CompanyDTO; + csrfToken: string; + authorization?: TeleworkAuthorizationDTO | undefined; + authorizations: readonly TeleworkAuthorizationDTO[]; + initialPersonId?: string | undefined; + onSaved: () => void; +}; diff --git a/modules/companies/src/web/telework-authorizations/shared/ui/telework-authorization-form.tsx b/modules/companies/src/web/telework-authorizations/shared/ui/telework-authorization-form.tsx index 73e4934..182c31f 100644 --- a/modules/companies/src/web/telework-authorizations/shared/ui/telework-authorization-form.tsx +++ b/modules/companies/src/web/telework-authorizations/shared/ui/telework-authorization-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; @@ -7,78 +6,24 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { - TeleworkAuthorizationDTO, - TeleworkAuthorizationInput, -} from "../../../../common/telework-authorizations"; -import { TeleworkAuthorizationInputSchema } from "../../../../common/telework-authorizations"; import { PersonEditorDialog } from "../../../people"; -import { remoteKeys } from "../../../remote-connections"; -import { teleworkApi, teleworkKeys } from "../api"; +import { useTeleworkAuthorizationFormController } from "../controllers"; +import type { TeleworkAuthorizationFormProps } from "../types"; import { TeleworkFeedback } from "./telework-feedback"; -export function TeleworkAuthorizationForm({ - company, - csrfToken, - authorization, - authorizations, - initialPersonId, - onSaved, -}: { - company: CompanyDTO; - csrfToken: string; - authorization?: TeleworkAuthorizationDTO | undefined; - authorizations: readonly TeleworkAuthorizationDTO[]; - initialPersonId?: string | undefined; - onSaved: () => void; -}) { +export function TeleworkAuthorizationForm(props: TeleworkAuthorizationFormProps) { + const { company, csrfToken, authorization } = props; + const { quick, setQuick, form, values, fixed, mutation, reserved, submit, personCreated } = + useTeleworkAuthorizationFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState(false); - const form = useForm({ - resolver: zodResolver(TeleworkAuthorizationInputSchema), - defaultValues: authorization?.values ?? { - personId: initialPersonId ?? "", - active: true, - startsOn: null, - expiresOn: null, - notes: null, - }, - }); - const values = form.watch(); - const fixed = Boolean(authorization || initialPersonId); - const mutation = useMutation({ - mutationFn: (input: TeleworkAuthorizationInput) => - teleworkApi.save(company.id, input, csrfToken, authorization?.id), - onSuccess: async () => { - await Promise.all([ - client.invalidateQueries({ queryKey: teleworkKeys.company(company.id) }), - client.invalidateQueries({ queryKey: remoteKeys.company(company.id) }), - ]); - if (mounted.current) { - onSaved(); - } - }, - }); - const reserved = new Set(authorizations.map((item) => item.values.personId)); return ( <> - mutation.mutate(input))}> +
@@ -168,12 +113,7 @@ export function TeleworkAuthorizationForm({ setQuick(false); } }} - onSaved={(id) => { - if (mounted.current) { - form.setValue("personId", id); - setQuick(false); - } - }} + onSaved={personCreated} open requireActive /> diff --git a/modules/companies/src/web/tickets/create/controllers/index.ts b/modules/companies/src/web/tickets/create/controllers/index.ts new file mode 100644 index 0000000..2bc0dc1 --- /dev/null +++ b/modules/companies/src/web/tickets/create/controllers/index.ts @@ -0,0 +1 @@ +export { useTicketQuickEditorController } from "./use-ticket-quick-editor-controller"; diff --git a/modules/companies/src/web/tickets/create/controllers/use-ticket-quick-editor-controller.ts b/modules/companies/src/web/tickets/create/controllers/use-ticket-quick-editor-controller.ts new file mode 100644 index 0000000..5b20721 --- /dev/null +++ b/modules/companies/src/web/tickets/create/controllers/use-ticket-quick-editor-controller.ts @@ -0,0 +1,27 @@ +import { catalogApi, catalogKeys } from "@metis/assets/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { getCompany } from "../../../companies/detail/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import type { TicketQuickEditorProps } from "../types"; + +export function useTicketQuickEditorController({ companyId, kind }: TicketQuickEditorProps) { + const company = useQuery({ + queryKey: companyKeys.detail(companyId), + queryFn: () => getCompany(companyId), + }); + const catalog = useQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + enabled: kind === "asset", + }); + const [nested, setNested] = useState< + | { + kind: "person" | "site"; + onSaved: (id: string) => void; + } + | undefined + >(); + return { company, catalog, nested, setNested }; +} diff --git a/modules/companies/src/web/tickets/create/types/index.ts b/modules/companies/src/web/tickets/create/types/index.ts new file mode 100644 index 0000000..7dd4248 --- /dev/null +++ b/modules/companies/src/web/tickets/create/types/index.ts @@ -0,0 +1 @@ +export type { TicketQuickEditorProps } from "./ticket-quick-editor-props"; diff --git a/modules/companies/src/web/tickets/create/types/ticket-quick-editor-props.ts b/modules/companies/src/web/tickets/create/types/ticket-quick-editor-props.ts new file mode 100644 index 0000000..83fd285 --- /dev/null +++ b/modules/companies/src/web/tickets/create/types/ticket-quick-editor-props.ts @@ -0,0 +1,8 @@ +import type { TicketTargetInput } from "../../../../common/tickets"; + +export type TicketQuickEditorProps = { + companyId: string; + kind: TicketTargetInput["kind"]; + csrfToken: string; + onSaved: (id: string) => void; +}; diff --git a/modules/companies/src/web/tickets/create/ui/ticket-quick-editor.tsx b/modules/companies/src/web/tickets/create/ui/ticket-quick-editor.tsx index 1245544..80375b9 100644 --- a/modules/companies/src/web/tickets/create/ui/ticket-quick-editor.tsx +++ b/modules/companies/src/web/tickets/create/ui/ticket-quick-editor.tsx @@ -1,39 +1,18 @@ -import { AssetForm, WorkstationForm, catalogApi, catalogKeys } from "@metis/assets/client"; +import { AssetForm, WorkstationForm } from "@metis/assets/client"; import { ResourceEditor } from "@metis/resources/client"; import { FormDialog } from "@repo/shadcn-ui/components/form-dialog"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { TicketTargetInput } from "../../../../common/tickets"; -import { companyApi, companyKeys } from "../../../companies/shared"; import { PersonForm } from "../../../people"; import { SiteForm } from "../../../sites"; import { TicketFeedback } from "../../shared/ui/ticket-feedback"; -export function TicketQuickEditor({ - companyId, - kind, - csrfToken, - onSaved, -}: { - companyId: string; - kind: TicketTargetInput["kind"]; - csrfToken: string; - onSaved: (id: string) => void; -}) { +import { useTicketQuickEditorController } from "../controllers"; +import type { TicketQuickEditorProps } from "../types"; + +export function TicketQuickEditor(props: TicketQuickEditorProps) { + const { companyId, kind, csrfToken, onSaved } = props; + const { company, catalog, nested, setNested } = useTicketQuickEditorController(props); const { t } = useTranslation("companies"); - const company = useQuery({ - queryKey: companyKeys.detail(companyId), - queryFn: () => companyApi.get(companyId), - }); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - enabled: kind === "asset", - }); - const [nested, setNested] = useState< - { kind: "person" | "site"; onSaved: (id: string) => void } | undefined - >(); if (company.isError || (kind === "asset" && catalog.isError)) { return ; } diff --git a/modules/companies/src/web/tickets/detail/ui/ticket-detail.tsx b/modules/companies/src/web/tickets/detail/ui/ticket-detail.tsx index f08380c..8bf94da 100644 --- a/modules/companies/src/web/tickets/detail/ui/ticket-detail.tsx +++ b/modules/companies/src/web/tickets/detail/ui/ticket-detail.tsx @@ -3,7 +3,8 @@ import { useTranslation } from "react-i18next"; import type { TicketDTO, TicketListDTO } from "../../../../common/tickets"; import { TicketInterventions } from "../../../interventions/list/ui/ticket-interventions"; -import { ticketApi, ticketKeys, ticketTargetOptions } from "../../shared"; +import { ticketApi, ticketKeys } from "../../shared/api"; +import { ticketTargetOptions } from "../../shared/helpers"; export function TicketDetail({ ticket, data }: { ticket: TicketDTO; data: TicketListDTO }) { const { t, i18n } = useTranslation("companies"); const query = useQuery({ diff --git a/modules/companies/src/web/tickets/index.ts b/modules/companies/src/web/tickets/index.ts index 7b65d22..a14b882 100644 --- a/modules/companies/src/web/tickets/index.ts +++ b/modules/companies/src/web/tickets/index.ts @@ -1,2 +1,4 @@ export { TicketsPage } from "./list"; -export { TicketForm, ticketApi, ticketKeys, ticketTargetOptions } from "./shared"; +export { TicketForm } from "./shared"; +export { ticketApi, ticketKeys } from "./shared/api"; +export { ticketTargetOptions } from "./shared/helpers"; diff --git a/modules/companies/src/web/tickets/list/controllers/index.ts b/modules/companies/src/web/tickets/list/controllers/index.ts new file mode 100644 index 0000000..4ef9aed --- /dev/null +++ b/modules/companies/src/web/tickets/list/controllers/index.ts @@ -0,0 +1 @@ +export { useTicketsPageController } from "./use-tickets-page-controller"; diff --git a/modules/companies/src/web/tickets/list/controllers/use-tickets-page-controller.ts b/modules/companies/src/web/tickets/list/controllers/use-tickets-page-controller.ts new file mode 100644 index 0000000..0c2b001 --- /dev/null +++ b/modules/companies/src/web/tickets/list/controllers/use-tickets-page-controller.ts @@ -0,0 +1,63 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import type { TicketDTO } from "../../../../common/tickets"; +import { ticketApi, ticketKeys } from "../../shared/api"; +import { filterTickets } from "../helpers"; +import type { TicketFilters, TicketsPageProps } from "../types"; + +function formatDate(value: string | undefined, language: string) { + return value + ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format( + new Date(value) + ) + : ""; +} +export function useTicketsPageController({ + companyId, + initialId, + initialCreate = false, + initialPendingOnly = false, +}: TicketsPageProps) { + const { i18n } = useTranslation("companies"); + const query = useQuery({ + queryKey: ticketKeys.list(companyId), + queryFn: () => ticketApi.list(companyId), + }); + const [filters, setFilters] = useState({ + search: "", + company: "", + state: "", + priority: "", + assignee: "", + }); + const [pendingOnly, setPendingOnly] = useState(initialPendingOnly); + const [viewId, setViewId] = useState(initialId); + const [open, setOpen] = useState(initialCreate); + const [edit, setEdit] = useState(); + const [interventionTicketId, setInterventionTicketId] = useState(); + const currentView = query.data?.tickets.find((ticket) => ticket.id === viewId); + const interventionTicket = query.data?.tickets.find( + (ticket) => ticket.id === interventionTicketId + ); + const items = filterTickets(query.data?.tickets ?? [], filters, pendingOnly); + const date = (value: string | undefined) => formatDate(value, i18n.language); + return { + query, + filters, + setFilters, + pendingOnly, + setPendingOnly, + setViewId, + open, + setOpen, + edit, + setEdit, + setInterventionTicketId, + currentView, + interventionTicket, + items, + date, + }; +} diff --git a/modules/companies/src/web/tickets/list/helpers/index.ts b/modules/companies/src/web/tickets/list/helpers/index.ts new file mode 100644 index 0000000..00b39ea --- /dev/null +++ b/modules/companies/src/web/tickets/list/helpers/index.ts @@ -0,0 +1 @@ +export { filterTickets } from "./tickets-filtering"; diff --git a/modules/companies/src/web/tickets/list/helpers/tickets-filtering.ts b/modules/companies/src/web/tickets/list/helpers/tickets-filtering.ts new file mode 100644 index 0000000..d7c019b --- /dev/null +++ b/modules/companies/src/web/tickets/list/helpers/tickets-filtering.ts @@ -0,0 +1,29 @@ +import type { TicketDTO } from "../../../../common/tickets"; +import type { TicketFilters } from "../types"; + +export function filterTickets( + items: readonly TicketDTO[], + filters: TicketFilters, + pendingOnly = false +) { + const search = filters.search.trim().toLocaleLowerCase(); + return items + .filter( + (item) => + !(pendingOnly && ["resolved", "cancelled"].includes(item.values.state)) && + (!filters.company || item.companyId === filters.company) && + (!filters.state || item.values.state === filters.state) && + (!filters.priority || item.values.priority === filters.priority) && + (!filters.assignee || + (filters.assignee === "unassigned" + ? item.values.assigneeId === null + : item.values.assigneeId === filters.assignee)) && + (!search || + [item.reference, item.values.subject, item.companyName, item.requesterName ?? ""].some( + (text) => text.toLocaleLowerCase().includes(search) + )) + ) + .sort((a, b) => + (b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "") + ); +} diff --git a/modules/companies/src/web/tickets/list/index.ts b/modules/companies/src/web/tickets/list/index.ts index d04159d..7fb0057 100644 --- a/modules/companies/src/web/tickets/list/index.ts +++ b/modules/companies/src/web/tickets/list/index.ts @@ -1 +1,2 @@ -export { TicketsPage, filterTickets } from "./ui"; +export { filterTickets } from "./helpers"; +export { TicketsPage } from "./ui"; diff --git a/modules/companies/src/web/tickets/list/types/index.ts b/modules/companies/src/web/tickets/list/types/index.ts new file mode 100644 index 0000000..4d7f604 --- /dev/null +++ b/modules/companies/src/web/tickets/list/types/index.ts @@ -0,0 +1 @@ +export type { TicketFilters, TicketsPageProps } from "./tickets-page-props"; diff --git a/modules/companies/src/web/tickets/list/types/tickets-page-props.ts b/modules/companies/src/web/tickets/list/types/tickets-page-props.ts new file mode 100644 index 0000000..3e0dc8e --- /dev/null +++ b/modules/companies/src/web/tickets/list/types/tickets-page-props.ts @@ -0,0 +1,15 @@ +export type TicketFilters = { + search: string; + company: string; + state: string; + priority: string; + assignee: string; +}; + +export type TicketsPageProps = { + companyId?: string | undefined; + csrfToken: string; + initialId?: string | undefined; + initialCreate?: boolean | undefined; + initialPendingOnly?: boolean | undefined; +}; diff --git a/modules/companies/src/web/tickets/list/ui/index.ts b/modules/companies/src/web/tickets/list/ui/index.ts index 99ccaff..0dfb611 100644 --- a/modules/companies/src/web/tickets/list/ui/index.ts +++ b/modules/companies/src/web/tickets/list/ui/index.ts @@ -1 +1 @@ -export { TicketsPage, filterTickets } from "./tickets-page"; +export { TicketsPage } from "./tickets-page"; diff --git a/modules/companies/src/web/tickets/list/ui/tickets-page.tsx b/modules/companies/src/web/tickets/list/ui/tickets-page.tsx index 5d93397..72baf0a 100644 --- a/modules/companies/src/web/tickets/list/ui/tickets-page.tsx +++ b/modules/companies/src/web/tickets/list/ui/tickets-page.tsx @@ -7,85 +7,36 @@ import { EntityTable, } from "@repo/shadcn-ui/components/entity-list"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { TicketDTO } from "../../../../common/tickets"; import { TicketPrioritySchema, TicketStateSchema } from "../../../../common/tickets"; import { CompanyIdentity, CompanySelect } from "../../../companies/shared"; import { TicketInterventionDialog } from "../../../interventions/create"; import { TicketDetailDialog } from "../../detail"; -import { TicketEditorDialog, TicketFeedback, ticketApi, ticketKeys } from "../../shared"; -export type TicketFilters = { - search: string; - company: string; - state: string; - priority: string; - assignee: string; -}; -export function filterTickets( - items: readonly TicketDTO[], - filters: TicketFilters, - pendingOnly = false -) { - const search = filters.search.trim().toLocaleLowerCase(); - return items - .filter( - (item) => - !(pendingOnly && ["resolved", "cancelled"].includes(item.values.state)) && - (!filters.company || item.companyId === filters.company) && - (!filters.state || item.values.state === filters.state) && - (!filters.priority || item.values.priority === filters.priority) && - (!filters.assignee || - (filters.assignee === "unassigned" - ? item.values.assigneeId === null - : item.values.assigneeId === filters.assignee)) && - (!search || - [item.reference, item.values.subject, item.companyName, item.requesterName ?? ""].some( - (text) => text.toLocaleLowerCase().includes(search) - )) - ) - .sort((a, b) => - (b.history.at(-1)?.recordedAt ?? "").localeCompare(a.history.at(-1)?.recordedAt ?? "") - ); -} -export function TicketsPage({ - companyId, - csrfToken, - initialId, - initialCreate = false, - initialPendingOnly = false, -}: { - companyId?: string | undefined; - csrfToken: string; - initialId?: string | undefined; - initialCreate?: boolean | undefined; - initialPendingOnly?: boolean | undefined; -}) { +import { TicketEditorDialog, TicketFeedback } from "../../shared"; +import { useTicketsPageController } from "../controllers"; +import type { TicketsPageProps } from "../types"; + +export function TicketsPage(props: TicketsPageProps) { + const { companyId, csrfToken } = props; + const { + query, + filters, + setFilters, + pendingOnly, + setPendingOnly, + setViewId, + open, + setOpen, + edit, + setEdit, + setInterventionTicketId, + currentView, + interventionTicket, + items, + date, + } = useTicketsPageController(props); const { t, i18n } = useTranslation("companies"); - const query = useQuery({ - queryKey: ticketKeys.list(companyId), - queryFn: () => ticketApi.list(companyId), - }); - const [filters, setFilters] = useState({ - search: "", - company: "", - state: "", - priority: "", - assignee: "", - }); - const [pendingOnly, setPendingOnly] = useState(initialPendingOnly); - const [viewId, setViewId] = useState(initialId); - const [open, setOpen] = useState(initialCreate); - const [edit, setEdit] = useState(); - const [interventionTicketId, setInterventionTicketId] = useState(); - const currentView = query.data?.tickets.find((ticket) => ticket.id === viewId); - const interventionTicket = query.data?.tickets.find( - (ticket) => ticket.id === interventionTicketId - ); - const items = filterTickets(query.data?.tickets ?? [], filters, pendingOnly); - const date = (value: string | undefined) => formatDate(value, i18n.language); return (
); } - -function formatDate(value: string | undefined, language: string) { - return value - ? new Intl.DateTimeFormat(language, { dateStyle: "medium", timeStyle: "short" }).format( - new Date(value) - ) - : ""; -} diff --git a/modules/companies/src/web/tickets/shared/controllers/index.ts b/modules/companies/src/web/tickets/shared/controllers/index.ts new file mode 100644 index 0000000..6b7048b --- /dev/null +++ b/modules/companies/src/web/tickets/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useTicketFormController } from "./use-ticket-form-controller"; diff --git a/modules/companies/src/web/tickets/shared/controllers/use-ticket-form-controller.ts b/modules/companies/src/web/tickets/shared/controllers/use-ticket-form-controller.ts new file mode 100644 index 0000000..e04693a --- /dev/null +++ b/modules/companies/src/web/tickets/shared/controllers/use-ticket-form-controller.ts @@ -0,0 +1,81 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { UniqueID } from "@repo/rdx-ddd"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; + +import type { TicketInput, TicketTargetInput } from "../../../../common/tickets"; +import { TicketInputSchema } from "../../../../common/tickets"; +import { ticketApi, ticketKeys } from "../api"; +import type { TicketFormProps } from "../types"; + +export function useTicketFormController({ scope, ticket, csrfToken, onSaved }: TicketFormProps) { + const client = useQueryClient(); + const [companyId, setCompanyId] = useState(ticket?.companyId ?? scope ?? ""); + const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive()); + const [quick, setQuick] = useState<"company" | "person" | undefined>(); + const form = useForm({ + resolver: zodResolver(TicketInputSchema), + defaultValues: ticket?.values ?? { + subject: "", + description: "", + requesterId: null, + assigneeId: null, + channel: "email", + state: "open", + priority: "normal", + affected: [], + }, + }); + const values = form.watch(); + const lookups = useQuery({ + queryKey: ticketKeys.lookups(companyId), + queryFn: () => ticketApi.lookups(companyId), + enabled: Boolean(companyId), + }); + const mutation = useMutation({ + mutationFn: (input: TicketInput) => + ticketApi.save( + companyId, + { values: input, requestKey, expectedRevisionId: ticket?.history.at(-1)?.id ?? null }, + csrfToken, + ticket?.id + ), + onSuccess: async (response) => { + await client.invalidateQueries({ queryKey: ["tickets"] }); + onSaved(response.ticket.id); + }, + }); + const changeCompany = (id: string) => { + setCompanyId(id); + form.setValue("requesterId", null); + form.setValue("affected", []); + }; + const selectOptions = ( + items: readonly { + id: string; + name: string; + active: boolean; + }[], + current: string | null + ) => + items + .filter((item) => item.active || item.id === current) + .map((item) => ({ value: item.id, label: item.name })); + const setTargets = (affected: TicketTargetInput[]) => form.setValue("affected", affected); + const submit = form.handleSubmit((input) => mutation.mutate(input)); + return { + client, + companyId, + quick, + setQuick, + form, + values, + lookups, + mutation, + changeCompany, + selectOptions, + setTargets, + submit, + }; +} diff --git a/modules/companies/src/web/tickets/shared/helpers/index.ts b/modules/companies/src/web/tickets/shared/helpers/index.ts new file mode 100644 index 0000000..10cdd45 --- /dev/null +++ b/modules/companies/src/web/tickets/shared/helpers/index.ts @@ -0,0 +1 @@ +export { ticketTargetOptions } from "./ticket-target-options"; diff --git a/modules/companies/src/web/tickets/shared/helpers/ticket-target-options.ts b/modules/companies/src/web/tickets/shared/helpers/ticket-target-options.ts new file mode 100644 index 0000000..765bca2 --- /dev/null +++ b/modules/companies/src/web/tickets/shared/helpers/ticket-target-options.ts @@ -0,0 +1,14 @@ +import type { TicketLookupsDTO, TicketTargetInput } from "../../../../common/tickets"; + +export function ticketTargetOptions(lookups: TicketLookupsDTO, kind: TicketTargetInput["kind"]) { + switch (kind) { + case "person": + return lookups.people; + case "asset": + return lookups.assets; + case "resource": + return lookups.resources; + case "workstation": + return lookups.workstations; + } +} diff --git a/modules/companies/src/web/tickets/shared/index.ts b/modules/companies/src/web/tickets/shared/index.ts index a2e0ff1..59a0fb3 100644 --- a/modules/companies/src/web/tickets/shared/index.ts +++ b/modules/companies/src/web/tickets/shared/index.ts @@ -1,8 +1,3 @@ export { ticketApi, ticketKeys } from "./api"; -export { - TicketEditorDialog, - TicketFeedback, - TicketForm, - TicketTargets, - ticketTargetOptions, -} from "./ui"; +export { ticketTargetOptions } from "./helpers"; +export { TicketEditorDialog, TicketFeedback, TicketForm, TicketTargets } from "./ui"; diff --git a/modules/companies/src/web/tickets/shared/tickets.test.tsx b/modules/companies/src/web/tickets/shared/tickets.test.tsx index 7af207c..62f503b 100644 --- a/modules/companies/src/web/tickets/shared/tickets.test.tsx +++ b/modules/companies/src/web/tickets/shared/tickets.test.tsx @@ -10,7 +10,8 @@ import { I18nextProvider } from "react-i18next"; import type { TicketDTO, TicketListDTO } from "../../../common/tickets"; import { companiesEnglish, companiesSpanish } from "../../shared"; -import { TicketsPage, filterTickets } from "../list"; +import { TicketsPage } from "../list"; +import { filterTickets } from "../list/helpers"; import { ticketKeys } from "./api"; import { TicketForm } from "./ui"; diff --git a/modules/companies/src/web/tickets/shared/types/index.ts b/modules/companies/src/web/tickets/shared/types/index.ts new file mode 100644 index 0000000..2173f4b --- /dev/null +++ b/modules/companies/src/web/tickets/shared/types/index.ts @@ -0,0 +1 @@ +export type { TicketFormProps } from "./ticket-form-props"; diff --git a/modules/companies/src/web/tickets/shared/types/ticket-form-props.ts b/modules/companies/src/web/tickets/shared/types/ticket-form-props.ts new file mode 100644 index 0000000..510af26 --- /dev/null +++ b/modules/companies/src/web/tickets/shared/types/ticket-form-props.ts @@ -0,0 +1,9 @@ +import type { TicketDTO, TicketListDTO } from "../../../../common/tickets"; + +export type TicketFormProps = { + scope?: string; + ticket?: TicketDTO; + data: TicketListDTO; + csrfToken: string; + onSaved: (id: string) => void; +}; diff --git a/modules/companies/src/web/tickets/shared/ui/index.ts b/modules/companies/src/web/tickets/shared/ui/index.ts index b7dfb7d..515d2df 100644 --- a/modules/companies/src/web/tickets/shared/ui/index.ts +++ b/modules/companies/src/web/tickets/shared/ui/index.ts @@ -1,4 +1,4 @@ export { TicketEditorDialog } from "./ticket-editor-dialog"; export { TicketFeedback } from "./ticket-feedback"; export { TicketForm } from "./ticket-form"; -export { TicketTargets, ticketTargetOptions } from "./ticket-targets"; +export { TicketTargets } from "./ticket-targets"; diff --git a/modules/companies/src/web/tickets/shared/ui/ticket-form.tsx b/modules/companies/src/web/tickets/shared/ui/ticket-form.tsx index da89b0d..de398b1 100644 --- a/modules/companies/src/web/tickets/shared/ui/ticket-form.tsx +++ b/modules/companies/src/web/tickets/shared/ui/ticket-form.tsx @@ -1,98 +1,44 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { UniqueID } from "@repo/rdx-ddd"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { - TicketDTO, - TicketInput, - TicketListDTO, - TicketTargetInput, -} from "../../../../common/tickets"; -import { TicketInputSchema } from "../../../../common/tickets"; import { CompanySelect } from "../../../companies/shared"; import { TicketQuickForms } from "../../create/ui/ticket-quick-forms"; -import { ticketApi, ticketKeys } from "../api"; +import { ticketKeys } from "../api"; +import { useTicketFormController } from "../controllers"; +import type { TicketFormProps } from "../types"; import { TicketClassificationFields } from "./ticket-classification-fields"; import { TicketFeedback } from "./ticket-feedback"; import { TicketHelp } from "./ticket-help"; import { TicketTargets } from "./ticket-targets"; -export function TicketForm({ - scope, - ticket, - data, - csrfToken, - onSaved, -}: { - scope?: string; - ticket?: TicketDTO; - data: TicketListDTO; - csrfToken: string; - onSaved: (id: string) => void; -}) { + +export function TicketForm(props: TicketFormProps) { + const { scope, ticket, data, csrfToken } = props; + const { + client, + companyId, + quick, + setQuick, + form, + values, + lookups, + mutation, + changeCompany, + selectOptions, + setTargets, + submit, + } = useTicketFormController(props); const { t } = useTranslation("companies"); const prefix = useId(); - const client = useQueryClient(); - const [companyId, setCompanyId] = useState(ticket?.companyId ?? scope ?? ""); - const [requestKey] = useState(() => UniqueID.generateNewID().toPrimitive()); - const [quick, setQuick] = useState<"company" | "person" | undefined>(); - const form = useForm({ - resolver: zodResolver(TicketInputSchema), - defaultValues: ticket?.values ?? { - subject: "", - description: "", - requesterId: null, - assigneeId: null, - channel: "email", - state: "open", - priority: "normal", - affected: [], - }, - }); - const values = form.watch(); - const lookups = useQuery({ - queryKey: ticketKeys.lookups(companyId), - queryFn: () => ticketApi.lookups(companyId), - enabled: Boolean(companyId), - }); - const mutation = useMutation({ - mutationFn: (input: TicketInput) => - ticketApi.save( - companyId, - { values: input, requestKey, expectedRevisionId: ticket?.history.at(-1)?.id ?? null }, - csrfToken, - ticket?.id - ), - onSuccess: async (response) => { - await client.invalidateQueries({ queryKey: ["tickets"] }); - onSaved(response.ticket.id); - }, - }); - const changeCompany = (id: string) => { - setCompanyId(id); - form.setValue("requesterId", null); - form.setValue("affected", []); - }; - const selectOptions = ( - items: readonly { id: string; name: string; active: boolean }[], - current: string | null - ) => - items - .filter((item) => item.active || item.id === current) - .map((item) => ({ value: item.id, label: item.name })); - const setTargets = (affected: TicketTargetInput[]) => form.setValue("affected", affected); return ( <> - mutation.mutate(input))}> +
diff --git a/modules/companies/src/web/tickets/shared/ui/ticket-targets.tsx b/modules/companies/src/web/tickets/shared/ui/ticket-targets.tsx index c5c6c5e..02ce255 100644 --- a/modules/companies/src/web/tickets/shared/ui/ticket-targets.tsx +++ b/modules/companies/src/web/tickets/shared/ui/ticket-targets.tsx @@ -8,18 +8,8 @@ import { useTranslation } from "react-i18next"; import type { TicketLookupsDTO, TicketTargetInput } from "../../../../common/tickets"; import { TicketTargetCreateDialog } from "../../create/ui/ticket-target-create-dialog"; import { ticketKeys } from "../api"; -export function ticketTargetOptions(lookups: TicketLookupsDTO, kind: TicketTargetInput["kind"]) { - switch (kind) { - case "person": - return lookups.people; - case "asset": - return lookups.assets; - case "resource": - return lookups.resources; - case "workstation": - return lookups.workstations; - } -} +import { ticketTargetOptions } from "../helpers"; + export function TicketTargets({ companyId, csrfToken, diff --git a/modules/companies/src/web/work-environments/detail/hooks/use-environment-queries.ts b/modules/companies/src/web/work-environments/detail/hooks/use-environment-queries.ts index b11f0db..993aa7d 100644 --- a/modules/companies/src/web/work-environments/detail/hooks/use-environment-queries.ts +++ b/modules/companies/src/web/work-environments/detail/hooks/use-environment-queries.ts @@ -5,16 +5,17 @@ import { } from "@metis/resources/client"; import { useQuery } from "@tanstack/react-query"; -import { accessApi, accessKeys } from "../../../access"; -import { applicationApi, applicationKeys } from "../../../application-configurations"; -import { certificateApi, certificateKeys } from "../../../certificate-configurations"; -import { folderApi, folderKeys } from "../../../folder-mappings"; -import { mailApi, mailKeys } from "../../../mail-configurations"; -import { printerApi, printerKeys } from "../../../printer-configurations"; -import { remoteApi, remoteKeys } from "../../../remote-connections"; -import { teleworkApi, teleworkKeys } from "../../../telework-authorizations"; -import { profileApi, profileKeys } from "../../../work-profiles/shared"; -import type { EnvironmentSources } from "../../shared"; +import { getAccess } from "../../../access/list/api"; +import { accessKeys } from "../../../access/shared/queries"; +import { applicationApi, applicationKeys } from "../../../application-configurations/shared/api"; +import { certificateApi, certificateKeys } from "../../../certificate-configurations/shared/api"; +import { folderApi, folderKeys } from "../../../folder-mappings/shared/api"; +import { mailApi, mailKeys } from "../../../mail-configurations/shared/api"; +import { printerApi, printerKeys } from "../../../printer-configurations/shared/api"; +import { remoteApi, remoteKeys } from "../../../remote-connections/shared/api"; +import { teleworkApi, teleworkKeys } from "../../../telework-authorizations/shared/api"; +import { profileApi, profileKeys } from "../../../work-profiles/shared/api"; +import type { EnvironmentSources } from "../../shared/helpers"; export function useEnvironmentQueries(companyId: string) { const profiles = useQuery({ @@ -31,7 +32,7 @@ export function useEnvironmentQueries(companyId: string) { }); const access = useQuery({ queryKey: accessKeys.scope(companyId), - queryFn: () => accessApi.request(companyId), + queryFn: () => getAccess(companyId), }); const mail = useQuery({ queryKey: mailKeys.company(companyId), diff --git a/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx b/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx index 66e4b95..837e8b7 100644 --- a/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx +++ b/modules/companies/src/web/work-environments/detail/ui/environment-content.tsx @@ -7,11 +7,11 @@ import { useTranslation } from "react-i18next"; import type { CompanyDTO, WorkProfileDTO } from "../../../../common"; import type { RemoteConnectionDTO } from "../../../../common/remote-connections"; import type { TeleworkAuthorizationDTO } from "../../../../common/telework-authorizations"; -import { accessLabel } from "../../../access"; +import { accessLabel } from "../../../access/shared/helpers"; import { RemoteSummary } from "../../../remote-connections"; import { TeleworkAuthorizationSummary } from "../../../telework-authorizations"; -import type { EnvironmentSources, ProfileConfigurationKind } from "../../shared"; -import { buildEnvironment } from "../../shared"; +import type { EnvironmentSources, ProfileConfigurationKind } from "../../shared/helpers"; +import { buildEnvironment } from "../../shared/helpers"; import { EnvironmentSection } from "./environment-section"; import { HardwareCharacteristics } from "./hardware-characteristics"; diff --git a/modules/companies/src/web/work-environments/detail/ui/environment-section.tsx b/modules/companies/src/web/work-environments/detail/ui/environment-section.tsx index 4f5ddca..5c79c5d 100644 --- a/modules/companies/src/web/work-environments/detail/ui/environment-section.tsx +++ b/modules/companies/src/web/work-environments/detail/ui/environment-section.tsx @@ -2,7 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Card, CardContent } from "@repo/shadcn-ui/components/card"; import { useTranslation } from "react-i18next"; -import type { EnvironmentRow, ProfileConfigurationKind } from "../../shared"; +import type { EnvironmentRow, ProfileConfigurationKind } from "../../shared/helpers"; export function EnvironmentSection({ attention, diff --git a/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx b/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx index 90c1b33..1ada6f1 100644 --- a/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx +++ b/modules/companies/src/web/work-environments/detail/ui/profile-configurations.tsx @@ -9,7 +9,7 @@ import { MailConfigurationsPage } from "../../../mail-configurations"; import { PrinterConfigurationsPage } from "../../../printer-configurations"; import { RemoteConnectionsPage } from "../../../remote-connections"; import { TeleworkAuthorizationsPage } from "../../../telework-authorizations"; -import type { ProfileSelection } from "../../../work-profiles/shared"; +import type { ProfileSelection } from "../../../work-profiles/shared/types"; import { WorkEnvironmentPage } from "./work-environment-page"; diff --git a/modules/companies/src/web/work-environments/detail/ui/work-environment-page.tsx b/modules/companies/src/web/work-environments/detail/ui/work-environment-page.tsx index 1436aaa..68c10d2 100644 --- a/modules/companies/src/web/work-environments/detail/ui/work-environment-page.tsx +++ b/modules/companies/src/web/work-environments/detail/ui/work-environment-page.tsx @@ -2,7 +2,7 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { useTranslation } from "react-i18next"; import type { CompanyDTO } from "../../../../common"; -import type { ProfileConfigurationKind } from "../../shared"; +import type { ProfileConfigurationKind } from "../../shared/helpers"; import { useEnvironmentQueries } from "../hooks"; import { EnvironmentContent } from "./environment-content"; diff --git a/modules/companies/src/web/work-environments/shared/work-environment.test.tsx b/modules/companies/src/web/work-environments/shared/work-environment.test.tsx index 3e6cea4..90480dc 100644 --- a/modules/companies/src/web/work-environments/shared/work-environment.test.tsx +++ b/modules/companies/src/web/work-environments/shared/work-environment.test.tsx @@ -20,16 +20,16 @@ import { } from "../../../common"; import { RemoteConnectionSchema } from "../../../common/remote-connections"; import { TeleworkAuthorizationSchema } from "../../../common/telework-authorizations"; -import { accessKeys } from "../../access"; -import { applicationKeys } from "../../application-configurations"; -import { certificateKeys } from "../../certificate-configurations"; -import { folderKeys } from "../../folder-mappings"; -import { mailKeys } from "../../mail-configurations"; -import { printerKeys } from "../../printer-configurations"; -import { remoteKeys } from "../../remote-connections"; +import { accessKeys } from "../../access/shared/queries"; +import { applicationKeys } from "../../application-configurations/shared/api"; +import { certificateKeys } from "../../certificate-configurations/shared/api"; +import { folderKeys } from "../../folder-mappings/shared/api"; +import { mailKeys } from "../../mail-configurations/shared/api"; +import { printerKeys } from "../../printer-configurations/shared/api"; +import { remoteKeys } from "../../remote-connections/shared/api"; import { companiesEnglish, companiesSpanish } from "../../shared"; -import { teleworkKeys } from "../../telework-authorizations"; -import { profileKeys } from "../../work-profiles"; +import { teleworkKeys } from "../../telework-authorizations/shared/api"; +import { profileKeys } from "../../work-profiles/shared/api"; import { WorkEnvironmentPage } from "../detail"; import type { EnvironmentSources } from "./helpers"; diff --git a/modules/companies/src/web/work-profiles/index.ts b/modules/companies/src/web/work-profiles/index.ts index b6578b6..daae5b0 100644 --- a/modules/companies/src/web/work-profiles/index.ts +++ b/modules/companies/src/web/work-profiles/index.ts @@ -1,2 +1,2 @@ export { WorkProfilesPage } from "./list"; -export { profileKeys } from "./shared"; +export { profileKeys } from "./shared/api"; diff --git a/modules/companies/src/web/work-profiles/list/controllers/index.ts b/modules/companies/src/web/work-profiles/list/controllers/index.ts new file mode 100644 index 0000000..d39afc4 --- /dev/null +++ b/modules/companies/src/web/work-profiles/list/controllers/index.ts @@ -0,0 +1 @@ +export { useWorkProfilesPageController } from "./use-work-profiles-page-controller"; diff --git a/modules/companies/src/web/work-profiles/list/controllers/use-work-profiles-page-controller.ts b/modules/companies/src/web/work-profiles/list/controllers/use-work-profiles-page-controller.ts new file mode 100644 index 0000000..dea30f4 --- /dev/null +++ b/modules/companies/src/web/work-profiles/list/controllers/use-work-profiles-page-controller.ts @@ -0,0 +1,76 @@ +import { inventoryApi, inventoryKeys } from "@metis/assets/client"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { getAccess } from "../../../access/list/api"; +import { accessLabel } from "../../../access/shared/helpers"; +import { accessKeys } from "../../../access/shared/queries"; +import { profileApi, profileKeys } from "../../shared/api"; +import type { ProfileSelection } from "../../shared/types"; +import type { WorkProfilesPageProps } from "../types"; + +export function useWorkProfilesPageController({ company, initialId }: WorkProfilesPageProps) { + const { t } = useTranslation("companies"); + const [configuration, setConfiguration] = useState( + initialId ? { kind: "environment", profileId: initialId } : undefined + ); + const [editor, setEditor] = useState(); + const [view, setView] = useState(); + const [search, setSearch] = useState(""); + const [archived, setArchived] = useState(false); + const query = useQuery({ + queryKey: profileKeys.company(company.id), + queryFn: () => profileApi.get(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const access = useQuery({ + queryKey: accessKeys.scope(company.id), + queryFn: () => getAccess(company.id), + }); + const profiles = query.data?.profiles ?? []; + const edited = profiles.find((item) => item.id === editor); + const viewed = profiles.find((item) => item.id === view); + const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; + const asset = (id: string) => + inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; + const account = (id: string | null) => { + const item = access.data?.items.find((item) => item.id === id); + return item + ? accessLabel(item, access.data?.items ?? [], company.people) + : (id ?? t("profiles.noAccount")); + }; + const filtered = profiles.filter( + (item) => + (archived || item.values.active) && + [item.values.name, person(item.values.personId), asset(item.values.assetId)].some((value) => + value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) + ) + ); + const ready = [query.data, inventory.data, access.data].every(Boolean); + return { + configuration, + setConfiguration, + editor, + setEditor, + setView, + search, + setSearch, + archived, + setArchived, + query, + inventory, + access, + profiles, + edited, + viewed, + person, + asset, + account, + filtered, + ready, + }; +} diff --git a/modules/companies/src/web/work-profiles/list/types/index.ts b/modules/companies/src/web/work-profiles/list/types/index.ts new file mode 100644 index 0000000..ac17bac --- /dev/null +++ b/modules/companies/src/web/work-profiles/list/types/index.ts @@ -0,0 +1 @@ +export type { WorkProfilesPageProps } from "./work-profiles-page-props"; diff --git a/modules/companies/src/web/work-profiles/list/types/work-profiles-page-props.ts b/modules/companies/src/web/work-profiles/list/types/work-profiles-page-props.ts new file mode 100644 index 0000000..a3cd2ed --- /dev/null +++ b/modules/companies/src/web/work-profiles/list/types/work-profiles-page-props.ts @@ -0,0 +1,7 @@ +import type { CompanyDTO } from "../../../../common"; + +export type WorkProfilesPageProps = { + company: CompanyDTO; + csrfToken: string; + initialId?: string | undefined; +}; diff --git a/modules/companies/src/web/work-profiles/list/ui/work-profiles-page.tsx b/modules/companies/src/web/work-profiles/list/ui/work-profiles-page.tsx index 19537d8..254bdab 100644 --- a/modules/companies/src/web/work-profiles/list/ui/work-profiles-page.tsx +++ b/modules/companies/src/web/work-profiles/list/ui/work-profiles-page.tsx @@ -1,4 +1,3 @@ -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { EntityAction, @@ -7,66 +6,39 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import { accessApi, accessKeys, accessLabel } from "../../../access"; import { ProfileConfigurations } from "../../../work-environments"; import { WorkProfileHistoryDialog } from "../../history"; -import type { ProfileSelection } from "../../shared"; -import { ProfileFeedback, WorkProfileEditorDialog, profileApi, profileKeys } from "../../shared"; +import { ProfileFeedback, WorkProfileEditorDialog } from "../../shared"; +import { useWorkProfilesPageController } from "../controllers"; +import type { WorkProfilesPageProps } from "../types"; -export function WorkProfilesPage({ - company, - csrfToken, - initialId, -}: { - company: CompanyDTO; - csrfToken: string; - initialId?: string | undefined; -}) { +export function WorkProfilesPage(props: WorkProfilesPageProps) { + const { company, csrfToken } = props; + const { + configuration, + setConfiguration, + editor, + setEditor, + setView, + search, + setSearch, + archived, + setArchived, + query, + inventory, + access, + profiles, + edited, + viewed, + person, + asset, + account, + filtered, + ready, + } = useWorkProfilesPageController(props); const { t, i18n } = useTranslation("companies"); - const [configuration, setConfiguration] = useState( - initialId ? { kind: "environment", profileId: initialId } : undefined - ); - const [editor, setEditor] = useState(); - const [view, setView] = useState(); - const [search, setSearch] = useState(""); - const [archived, setArchived] = useState(false); - const query = useQuery({ - queryKey: profileKeys.company(company.id), - queryFn: () => profileApi.get(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const access = useQuery({ - queryKey: accessKeys.scope(company.id), - queryFn: () => accessApi.request(company.id), - }); - const profiles = query.data?.profiles ?? []; - const edited = profiles.find((item) => item.id === editor); - const viewed = profiles.find((item) => item.id === view); - const person = (id: string) => company.people.find((item) => item.id === id)?.name ?? id; - const asset = (id: string) => - inventory.data?.assets.find((item) => item.id === id)?.values.name ?? id; - const account = (id: string | null) => { - const item = access.data?.items.find((item) => item.id === id); - return item - ? accessLabel(item, access.data?.items ?? [], company.people) - : (id ?? t("profiles.noAccount")); - }; - const filtered = profiles.filter( - (item) => - (archived || item.values.active) && - [item.values.name, person(item.values.personId), asset(item.values.assetId)].some((value) => - value.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()) - ) - ); - const ready = [query.data, inventory.data, access.data].every(Boolean); if (configuration) { return ( { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const [quick, setQuick] = useState(); + const access = useQuery({ + queryKey: accessKeys.scope(company.id), + queryFn: () => getAccess(company.id), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(company.id), + queryFn: () => inventoryApi.get(company.id), + }); + const form = useForm({ + resolver: zodResolver(WorkProfileInputSchema), + defaultValues: profile?.values ?? { + name: "", + personId: "", + assetId: "", + accountId: null, + active: true, + notes: null, + }, + }); + const values = form.watch(); + const items = access.data?.items ?? []; + const today = new Date().toISOString().slice(0, 10); + const authorized = + !values.accountId || + items.some((item) => { + const link = item.values; + return ( + link.kind === "account_person" && + link.personId === values.personId && + link.accountId === values.accountId && + link.startDate <= today && + (link.endDate === null || today < link.endDate) && + (link.configuration === "pending" || link.configuration === "configured") + ); + }); + const mutation = useMutation({ + mutationFn: (input: WorkProfileInput) => + profileApi.save(company.id, input, csrfToken, profile?.id), + onSuccess: async (result) => { + await client.invalidateQueries({ queryKey: profileKeys.company(company.id) }); + if (mounted.current) { + onSaved(result.profile.id); + } + }, + }); + const disabled = mutation.isPending; + const selectors = [ + { + field: "personId", + label: "person", + create: "newPerson", + kind: "person", + options: company.people + .filter((item) => item.active || item.id === values.personId) + .map((item) => ({ value: item.id, label: item.name })), + }, + { + field: "assetId", + label: "asset", + create: "newAsset", + kind: "asset", + options: (inventory.data?.assets ?? []) + .filter((item) => item.values.status !== "retired" || item.id === values.assetId) + .map((item) => ({ value: item.id, label: item.values.name })), + }, + { + field: "accountId", + label: "account", + create: "newAccount", + kind: "account", + options: items + .filter( + (item) => + item.values.kind === "account" && + ((item.values.active && item.values.usage !== "technical") || + item.id === values.accountId) + ) + .map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })), + }, + ] as const; + const submit = form.handleSubmit((input) => mutation.mutate(input)); + const quickCreated = (field: "personId" | "accountId" | "assetId", id: string | undefined) => { + if (mounted.current && id) { + form.setValue(field, id); + setQuick(undefined); + } + }; + return { + quick, + setQuick, + access, + inventory, + form, + values, + authorized, + mutation, + disabled, + selectors, + submit, + quickCreated, + }; +} diff --git a/modules/companies/src/web/work-profiles/shared/profile.test.tsx b/modules/companies/src/web/work-profiles/shared/profile.test.tsx index af18891..4b6db5c 100644 --- a/modules/companies/src/web/work-profiles/shared/profile.test.tsx +++ b/modules/companies/src/web/work-profiles/shared/profile.test.tsx @@ -8,7 +8,7 @@ import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { I18nextProvider } from "react-i18next"; -import { accessKeys } from "../../access"; +import { accessKeys } from "../../access/shared/queries"; import { companiesEnglish, companiesSpanish } from "../../shared"; import { WorkProfilesPage } from "../list"; diff --git a/modules/companies/src/web/work-profiles/shared/types/index.ts b/modules/companies/src/web/work-profiles/shared/types/index.ts index b401c5a..62774df 100644 --- a/modules/companies/src/web/work-profiles/shared/types/index.ts +++ b/modules/companies/src/web/work-profiles/shared/types/index.ts @@ -1 +1,2 @@ export type { ProfileSelection } from "./profile-selection"; +export type { WorkProfileFormProps } from "./work-profile-form-props"; diff --git a/modules/companies/src/web/work-profiles/shared/types/profile-selection.ts b/modules/companies/src/web/work-profiles/shared/types/profile-selection.ts index 79cfaf0..d1ec56c 100644 --- a/modules/companies/src/web/work-profiles/shared/types/profile-selection.ts +++ b/modules/companies/src/web/work-profiles/shared/types/profile-selection.ts @@ -1,4 +1,4 @@ -import type { ProfileConfigurationKind } from "../../../work-environments/shared"; +import type { ProfileConfigurationKind } from "../../../work-environments/shared/helpers"; export type ProfileSelection = { kind: ProfileConfigurationKind | "environment" | "telework" | "remote"; diff --git a/modules/companies/src/web/work-profiles/shared/types/work-profile-form-props.ts b/modules/companies/src/web/work-profiles/shared/types/work-profile-form-props.ts new file mode 100644 index 0000000..e301b01 --- /dev/null +++ b/modules/companies/src/web/work-profiles/shared/types/work-profile-form-props.ts @@ -0,0 +1,10 @@ +import type { CompanyDTO } from "../../../../common"; +import type { WorkProfileDTO } from "../../../../common/work-profiles"; + +export type WorkProfileFormProps = { + company: CompanyDTO; + csrfToken: string; + profile?: WorkProfileDTO | undefined; + onSaved: (id: string) => void; + allowQuickCreate?: boolean; +}; diff --git a/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx b/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx index d5b45e1..e8c647c 100644 --- a/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx +++ b/modules/companies/src/web/work-profiles/shared/ui/work-profile-form.tsx @@ -1,5 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; -import { inventoryApi, inventoryKeys } from "@metis/assets/client"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -7,128 +5,36 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CompanyDTO } from "../../../../common"; -import type { WorkProfileDTO, WorkProfileInput } from "../../../../common/work-profiles"; -import { WorkProfileInputSchema } from "../../../../common/work-profiles"; -import { accessApi, accessKeys, accessLabel } from "../../../access"; -import type { ConfigurationQuickKind } from "../../../shared"; import { ConfigurationQuickEditor } from "../../../shared"; -import { profileApi, profileKeys } from "../api"; +import { useWorkProfileFormController } from "../controllers"; +import type { WorkProfileFormProps } from "../types"; import { ProfileFeedback } from "./profile-feedback"; -export function WorkProfileForm({ - company, - csrfToken, - profile, - onSaved, - allowQuickCreate = true, -}: { - company: CompanyDTO; - csrfToken: string; - profile?: WorkProfileDTO | undefined; - onSaved: (id: string) => void; - allowQuickCreate?: boolean; -}) { +export function WorkProfileForm(props: WorkProfileFormProps) { + const { company, csrfToken, profile, allowQuickCreate = true } = props; + const { + quick, + setQuick, + access, + inventory, + form, + values, + authorized, + mutation, + disabled, + selectors, + submit, + quickCreated, + } = useWorkProfileFormController(props); const { t } = useTranslation("companies"); - const client = useQueryClient(); const prefix = useId(); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const [quick, setQuick] = useState(); - const access = useQuery({ - queryKey: accessKeys.scope(company.id), - queryFn: () => accessApi.request(company.id), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(company.id), - queryFn: () => inventoryApi.get(company.id), - }); - const form = useForm({ - resolver: zodResolver(WorkProfileInputSchema), - defaultValues: profile?.values ?? { - name: "", - personId: "", - assetId: "", - accountId: null, - active: true, - notes: null, - }, - }); - const values = form.watch(); - const items = access.data?.items ?? []; - const today = new Date().toISOString().slice(0, 10); - const authorized = - !values.accountId || - items.some((item) => { - const link = item.values; - return ( - link.kind === "account_person" && - link.personId === values.personId && - link.accountId === values.accountId && - link.startDate <= today && - (link.endDate === null || today < link.endDate) && - (link.configuration === "pending" || link.configuration === "configured") - ); - }); - const mutation = useMutation({ - mutationFn: (input: WorkProfileInput) => - profileApi.save(company.id, input, csrfToken, profile?.id), - onSuccess: async (result) => { - await client.invalidateQueries({ queryKey: profileKeys.company(company.id) }); - if (mounted.current) { - onSaved(result.profile.id); - } - }, - }); - const disabled = mutation.isPending; - const selectors = [ - { - field: "personId", - label: "person", - create: "newPerson", - kind: "person", - options: company.people - .filter((item) => item.active || item.id === values.personId) - .map((item) => ({ value: item.id, label: item.name })), - }, - { - field: "assetId", - label: "asset", - create: "newAsset", - kind: "asset", - options: (inventory.data?.assets ?? []) - .filter((item) => item.values.status !== "retired" || item.id === values.assetId) - .map((item) => ({ value: item.id, label: item.values.name })), - }, - { - field: "accountId", - label: "account", - create: "newAccount", - kind: "account", - options: items - .filter( - (item) => - item.values.kind === "account" && - ((item.values.active && item.values.usage !== "technical") || - item.id === values.accountId) - ) - .map((item) => ({ value: item.id, label: accessLabel(item, items, company.people) })), - }, - ] as const; return ( <> - mutation.mutate(input))}> +
@@ -223,12 +129,7 @@ export function WorkProfileForm({ data={access.data} kind={quick} onClose={() => setQuick(undefined)} - onCreated={(field, id) => { - if (mounted.current && id) { - form.setValue(field, id); - setQuick(undefined); - } - }} + onCreated={quickCreated} values={{ personId: values.personId, accountId: values.accountId ?? "" }} /> )} diff --git a/modules/companies/src/web/workspace/shared/types/workspace-location.ts b/modules/companies/src/web/workspace/shared/types/workspace-location.ts index 498f786..2e74689 100644 --- a/modules/companies/src/web/workspace/shared/types/workspace-location.ts +++ b/modules/companies/src/web/workspace/shared/types/workspace-location.ts @@ -1,3 +1,3 @@ -import type { DashboardNavigation } from "../../../dashboard"; +import type { DashboardNavigation } from "../../../dashboard/shared/helpers"; export type WorkspaceLocation = { area: "home" } | DashboardNavigation | { area: "catalogs" }; diff --git a/modules/companies/src/web/workspace/view/controllers/index.ts b/modules/companies/src/web/workspace/view/controllers/index.ts new file mode 100644 index 0000000..0858a79 --- /dev/null +++ b/modules/companies/src/web/workspace/view/controllers/index.ts @@ -0,0 +1 @@ +export { useCompaniesWorkspaceController } from "./use-companies-workspace-controller"; diff --git a/modules/companies/src/web/workspace/view/controllers/use-companies-workspace-controller.ts b/modules/companies/src/web/workspace/view/controllers/use-companies-workspace-controller.ts new file mode 100644 index 0000000..508d531 --- /dev/null +++ b/modules/companies/src/web/workspace/view/controllers/use-companies-workspace-controller.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { listCompanies } from "../../../companies/list/api"; +import { companyKeys } from "../../../companies/shared/queries"; +import type { WorkspaceLocation } from "../../shared/types"; + +export function useCompaniesWorkspaceController() { + const query = useQuery({ queryKey: companyKeys.list, queryFn: listCompanies }); + const [location, setLocation] = useState(); + const [catalog, setCatalog] = useState< + "access" | "assets" | "brands" | "resources" | "intervention-templates" + >("access"); + const [settingsOpen, setSettingsOpen] = useState(false); + const companies = query.data?.companies ?? []; + const current = location ?? { area: "home" }; + const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" }); + return { + query, + setLocation, + catalog, + setCatalog, + settingsOpen, + setSettingsOpen, + companies, + current, + selectCompany, + }; +} diff --git a/modules/companies/src/web/workspace/view/types/companies-workspace-props.ts b/modules/companies/src/web/workspace/view/types/companies-workspace-props.ts new file mode 100644 index 0000000..976176f --- /dev/null +++ b/modules/companies/src/web/workspace/view/types/companies-workspace-props.ts @@ -0,0 +1,8 @@ +import type { ReactNode } from "react"; + +export type CompaniesWorkspaceProps = { + csrfToken: string; + accountNavigation: ReactNode; + userName: string; + languageSwitcher: ReactNode; +}; diff --git a/modules/companies/src/web/workspace/view/types/index.ts b/modules/companies/src/web/workspace/view/types/index.ts new file mode 100644 index 0000000..387103e --- /dev/null +++ b/modules/companies/src/web/workspace/view/types/index.ts @@ -0,0 +1 @@ +export type { CompaniesWorkspaceProps } from "./companies-workspace-props"; diff --git a/modules/companies/src/web/workspace/view/ui/companies-workspace.tsx b/modules/companies/src/web/workspace/view/ui/companies-workspace.tsx index ee6ce9f..b9be4a3 100644 --- a/modules/companies/src/web/workspace/view/ui/companies-workspace.tsx +++ b/modules/companies/src/web/workspace/view/ui/companies-workspace.tsx @@ -1,42 +1,30 @@ import { Button } from "@repo/shadcn-ui/components/button"; -import { useQuery } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { CompanyDetail, CompanyFeedback, companyApi, companyKeys } from "../../../companies"; +import { CompanyDetail, CompanyFeedback } from "../../../companies"; import { WorkspaceSettingsDialog } from "../../settings"; -import type { WorkspaceLocation } from "../../shared"; +import { useCompaniesWorkspaceController } from "../controllers"; +import type { CompaniesWorkspaceProps } from "../types"; import { WorkspaceContent } from "./workspace-content"; import { WorkspaceHeader } from "./workspace-header"; import { WorkspaceSidebar } from "./workspace-sidebar"; -export function CompaniesWorkspace({ - csrfToken, - accountNavigation, - userName, - languageSwitcher, -}: { - csrfToken: string; - accountNavigation: ReactNode; - userName: string; - languageSwitcher: ReactNode; -}) { +export function CompaniesWorkspace(props: CompaniesWorkspaceProps) { + const { csrfToken, accountNavigation, userName, languageSwitcher } = props; + const { + query, + setLocation, + catalog, + setCatalog, + settingsOpen, + setSettingsOpen, + companies, + current, + selectCompany, + } = useCompaniesWorkspaceController(); const { t } = useTranslation("companies"); const { t: coreTranslation } = useTranslation("core"); - - const query = useQuery({ queryKey: companyKeys.list, queryFn: companyApi.list }); - const [location, setLocation] = useState(); - const [catalog, setCatalog] = useState< - "access" | "assets" | "brands" | "resources" | "intervention-templates" - >("access"); - const [settingsOpen, setSettingsOpen] = useState(false); - - const companies = query.data?.companies ?? []; - const current = location ?? { area: "home" }; - const selectCompany = (id: string) => setLocation({ area: "company", id, section: "inventory" }); - return (
+ identityRequest("/auth/change-password", UserResponseSchema, body, token); diff --git a/modules/identity/src/web/passwords/change/api/index.ts b/modules/identity/src/web/passwords/change/api/index.ts new file mode 100644 index 0000000..46a39c9 --- /dev/null +++ b/modules/identity/src/web/passwords/change/api/index.ts @@ -0,0 +1 @@ +export { changePassword } from "./change-password.api"; diff --git a/modules/identity/src/web/passwords/reset/api/index.ts b/modules/identity/src/web/passwords/reset/api/index.ts new file mode 100644 index 0000000..63d26a3 --- /dev/null +++ b/modules/identity/src/web/passwords/reset/api/index.ts @@ -0,0 +1 @@ +export { resetUserPassword } from "./reset-user-password.api"; diff --git a/modules/identity/src/web/passwords/reset/api/reset-user-password.api.ts b/modules/identity/src/web/passwords/reset/api/reset-user-password.api.ts new file mode 100644 index 0000000..d7cdbdf --- /dev/null +++ b/modules/identity/src/web/passwords/reset/api/reset-user-password.api.ts @@ -0,0 +1,5 @@ +import { UserResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const resetUserPassword = (id: string, body: unknown, token: string) => + identityRequest(`/users/${id}/reset-password`, UserResponseSchema, body, token); diff --git a/modules/identity/src/web/passwords/shared/controllers/index.ts b/modules/identity/src/web/passwords/shared/controllers/index.ts new file mode 100644 index 0000000..dfa5e2e --- /dev/null +++ b/modules/identity/src/web/passwords/shared/controllers/index.ts @@ -0,0 +1 @@ +export { usePasswordFormController } from "./use-password-form-controller"; diff --git a/modules/identity/src/web/passwords/shared/controllers/use-password-form-controller.ts b/modules/identity/src/web/passwords/shared/controllers/use-password-form-controller.ts new file mode 100644 index 0000000..edaa174 --- /dev/null +++ b/modules/identity/src/web/passwords/shared/controllers/use-password-form-controller.ts @@ -0,0 +1,40 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { z } from "zod/v4"; + +import type { ChangePasswordRequestDTO } from "../../../../common"; +import { ChangePasswordRequestSchema, ResetPasswordRequestSchema } from "../../../../common"; +import { identityKeys } from "../../../shared/queries"; +import { changePassword } from "../../change/api"; +import { resetUserPassword } from "../../reset/api"; +import type { PasswordFormProps } from "../types"; + +export function usePasswordFormController({ target, csrfToken, onSaved }: PasswordFormProps) { + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver( + target + ? ChangePasswordRequestSchema.extend({ currentPassword: z.string() }) + : ChangePasswordRequestSchema + ), + defaultValues: { currentPassword: "", password: "" }, + }); + const mutation = useMutation({ + mutationFn: (values: ChangePasswordRequestDTO) => + target + ? resetUserPassword( + target.id, + ResetPasswordRequestSchema.parse({ password: values.password }), + csrfToken + ) + : changePassword(values, csrfToken), + onSuccess: async () => { + form.reset(); + await client.invalidateQueries({ queryKey: identityKeys.session }); + onSaved?.(); + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/identity/src/web/passwords/shared/types/index.ts b/modules/identity/src/web/passwords/shared/types/index.ts new file mode 100644 index 0000000..84b34d4 --- /dev/null +++ b/modules/identity/src/web/passwords/shared/types/index.ts @@ -0,0 +1 @@ +export type { PasswordFormProps } from "./password-form-props"; diff --git a/modules/identity/src/web/passwords/shared/types/password-form-props.ts b/modules/identity/src/web/passwords/shared/types/password-form-props.ts new file mode 100644 index 0000000..94f81b3 --- /dev/null +++ b/modules/identity/src/web/passwords/shared/types/password-form-props.ts @@ -0,0 +1,8 @@ +import type { UserDTO } from "../../../../common"; + +export type PasswordFormProps = { + target?: UserDTO; + csrfToken: string; + onSaved?: () => void; + showTitle?: boolean; +}; diff --git a/modules/identity/src/web/passwords/shared/ui/password-form.tsx b/modules/identity/src/web/passwords/shared/ui/password-form.tsx index e901ea4..540cdd1 100644 --- a/modules/identity/src/web/passwords/shared/ui/password-form.tsx +++ b/modules/identity/src/web/passwords/shared/ui/password-form.tsx @@ -1,57 +1,21 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { ChangePasswordRequestDTO, UserDTO } from "../../../../common"; -import { ChangePasswordRequestSchema, ResetPasswordRequestSchema } from "../../../../common"; -import { FormFeedback, identityApi, identityKeys } from "../../../shared"; +import { FormFeedback } from "../../../shared"; +import { usePasswordFormController } from "../controllers"; +import type { PasswordFormProps } from "../types"; -export function PasswordForm({ - target, - csrfToken, - onSaved, - showTitle = true, -}: { - target?: UserDTO; - csrfToken: string; - onSaved?: () => void; - showTitle?: boolean; -}) { +export function PasswordForm(props: PasswordFormProps) { + const { target, showTitle = true } = props; + const { form, mutation, submit } = usePasswordFormController(props); const { t } = useTranslation("identity"); const id = useId(); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver( - target - ? ChangePasswordRequestSchema.extend({ currentPassword: z.string() }) - : ChangePasswordRequestSchema - ), - defaultValues: { currentPassword: "", password: "" }, - }); - const mutation = useMutation({ - mutationFn: (values: ChangePasswordRequestDTO) => - target - ? identityApi.resetPassword( - target.id, - ResetPasswordRequestSchema.parse({ password: values.password }), - csrfToken - ) - : identityApi.changePassword(values, csrfToken), - onSuccess: async () => { - form.reset(); - await client.invalidateQueries({ queryKey: identityKeys.session }); - onSaved?.(); - }, - }); return ( - mutation.mutate(values))}> + {showTitle && (

{t(target ? "resetPassword" : "changePassword")} diff --git a/modules/identity/src/web/sessions/shared/api/authenticate.api.ts b/modules/identity/src/web/sessions/shared/api/authenticate.api.ts new file mode 100644 index 0000000..5022aa2 --- /dev/null +++ b/modules/identity/src/web/sessions/shared/api/authenticate.api.ts @@ -0,0 +1,5 @@ +import { UserResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const authenticate = (setup: boolean, body: unknown, token: string) => + identityRequest(setup ? "/auth/bootstrap" : "/auth/login", UserResponseSchema, body, token); diff --git a/modules/identity/src/web/sessions/shared/api/get-session.api.ts b/modules/identity/src/web/sessions/shared/api/get-session.api.ts new file mode 100644 index 0000000..faee806 --- /dev/null +++ b/modules/identity/src/web/sessions/shared/api/get-session.api.ts @@ -0,0 +1,4 @@ +import { SessionResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const getSession = () => identityRequest("/auth/session", SessionResponseSchema); diff --git a/modules/identity/src/web/sessions/shared/api/index.ts b/modules/identity/src/web/sessions/shared/api/index.ts new file mode 100644 index 0000000..c03126c --- /dev/null +++ b/modules/identity/src/web/sessions/shared/api/index.ts @@ -0,0 +1,3 @@ +export { authenticate } from "./authenticate.api"; +export { getSession } from "./get-session.api"; +export { logout } from "./logout.api"; diff --git a/modules/identity/src/web/sessions/shared/api/logout.api.ts b/modules/identity/src/web/sessions/shared/api/logout.api.ts new file mode 100644 index 0000000..873a0c3 --- /dev/null +++ b/modules/identity/src/web/sessions/shared/api/logout.api.ts @@ -0,0 +1,5 @@ +import { ActionResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const logout = (token: string) => + identityRequest("/auth/logout", ActionResponseSchema, {}, token); diff --git a/modules/identity/src/web/sessions/shared/controllers/index.ts b/modules/identity/src/web/sessions/shared/controllers/index.ts new file mode 100644 index 0000000..bebeee8 --- /dev/null +++ b/modules/identity/src/web/sessions/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useCredentialFormController } from "./use-credential-form-controller"; diff --git a/modules/identity/src/web/sessions/shared/controllers/use-credential-form-controller.ts b/modules/identity/src/web/sessions/shared/controllers/use-credential-form-controller.ts new file mode 100644 index 0000000..d1fa1a0 --- /dev/null +++ b/modules/identity/src/web/sessions/shared/controllers/use-credential-form-controller.ts @@ -0,0 +1,40 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; + +import type { BootstrapAdminRequestDTO } from "../../../../common"; +import { BootstrapAdminRequestSchema, LoginRequestSchema } from "../../../../common"; +import { identityKeys } from "../../../shared/queries"; +import { authenticate } from "../api"; +import type { CredentialFormProps } from "../types"; + +export function useCredentialFormController({ setup, csrfToken }: CredentialFormProps) { + const client = useQueryClient(); + const form = useForm({ + resolver: zodResolver( + setup + ? BootstrapAdminRequestSchema + : BootstrapAdminRequestSchema.extend({ password: LoginRequestSchema.shape.password }) + ), + defaultValues: { name: setup ? "" : "Login", email: "", password: "" }, + }); + // Cada flujo aplica su contrato sin pedir una contraseña nueva al iniciar sesión. + const mutation = useMutation({ + mutationFn: (values: BootstrapAdminRequestDTO) => + authenticate( + setup, + setup + ? BootstrapAdminRequestSchema.parse(values) + : LoginRequestSchema.parse({ email: values.email, password: values.password }), + csrfToken + ), + onSuccess: async () => { + client.removeQueries({ + predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session", + }); + await client.invalidateQueries({ queryKey: identityKeys.session }); + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/identity/src/web/sessions/shared/types/credential-form-props.ts b/modules/identity/src/web/sessions/shared/types/credential-form-props.ts new file mode 100644 index 0000000..9803f28 --- /dev/null +++ b/modules/identity/src/web/sessions/shared/types/credential-form-props.ts @@ -0,0 +1,4 @@ +export type CredentialFormProps = { + setup: boolean; + csrfToken: string; +}; diff --git a/modules/identity/src/web/sessions/shared/types/index.ts b/modules/identity/src/web/sessions/shared/types/index.ts new file mode 100644 index 0000000..207a46b --- /dev/null +++ b/modules/identity/src/web/sessions/shared/types/index.ts @@ -0,0 +1 @@ +export type { CredentialFormProps } from "./credential-form-props"; diff --git a/modules/identity/src/web/sessions/shared/ui/credential-form.tsx b/modules/identity/src/web/sessions/shared/ui/credential-form.tsx index 6e0caab..ff8a0ed 100644 --- a/modules/identity/src/web/sessions/shared/ui/credential-form.tsx +++ b/modules/identity/src/web/sessions/shared/ui/credential-form.tsx @@ -1,46 +1,18 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import type { BootstrapAdminRequestDTO } from "../../../../common"; -import { BootstrapAdminRequestSchema, LoginRequestSchema } from "../../../../common"; -import { FormFeedback, identityApi, identityKeys } from "../../../shared"; +import { FormFeedback } from "../../../shared"; +import { useCredentialFormController } from "../controllers"; +import type { CredentialFormProps } from "../types"; -export function CredentialForm({ setup, csrfToken }: { setup: boolean; csrfToken: string }) { +export function CredentialForm(props: CredentialFormProps) { + const { setup } = props; + const { form, mutation, submit } = useCredentialFormController(props); const { t } = useTranslation("identity"); const id = useId(); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver( - setup - ? BootstrapAdminRequestSchema - : BootstrapAdminRequestSchema.extend({ password: LoginRequestSchema.shape.password }) - ), - defaultValues: { name: setup ? "" : "Login", email: "", password: "" }, - }); - // Cada flujo aplica su contrato sin pedir una contraseña nueva al iniciar sesión. - const mutation = useMutation({ - mutationFn: (values: BootstrapAdminRequestDTO) => - identityApi.authenticate( - setup, - setup - ? BootstrapAdminRequestSchema.parse(values) - : LoginRequestSchema.parse({ email: values.email, password: values.password }), - csrfToken - ), - onSuccess: async () => { - client.removeQueries({ - predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session", - }); - await client.invalidateQueries({ queryKey: identityKeys.session }); - }, - }); - const submit = form.handleSubmit((values) => mutation.mutate(values)); return ( {setup && ( diff --git a/modules/identity/src/web/shared/api/identity-api-error.ts b/modules/identity/src/web/shared/api/identity-api-error.ts new file mode 100644 index 0000000..e1bf27d --- /dev/null +++ b/modules/identity/src/web/shared/api/identity-api-error.ts @@ -0,0 +1,5 @@ +export class IdentityApiError extends Error { + constructor(public readonly code: string) { + super(code); + } +} diff --git a/modules/identity/src/web/shared/api/identity-api.test.ts b/modules/identity/src/web/shared/api/identity-api.test.ts new file mode 100644 index 0000000..714e0a8 --- /dev/null +++ b/modules/identity/src/web/shared/api/identity-api.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getSession, logout } from "../../sessions/shared/api"; + +import { identityApi } from "./identity-api"; +import { IdentityApiError } from "./identity-api-error"; + +const USER_ID = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4"; +const CSRF_TOKEN = "a".repeat(32); +const USER = { + id: USER_ID, + name: "Ana", + email: "ana@example.test", + active: true, + isInitialAdmin: false, +}; + +test("la sesión valida la respuesta y conserva el contrato público utilizado por la aplicación", async (context) => { + context.mock.method(globalThis, "fetch", async (url: unknown, options: RequestInit) => { + assert.equal(url, "/api/auth/session"); + assert.equal(options.method, "GET"); + assert.equal(options.credentials, "same-origin"); + assert.equal(options.body, undefined); + return Response.json({ user: USER, setupRequired: false, csrfToken: CSRF_TOKEN }); + }); + assert.equal(identityApi.session, getSession); + assert.deepEqual(await identityApi.session(), { + user: USER, + setupRequired: false, + csrfToken: CSRF_TOKEN, + }); +}); + +test("login, configuración inicial, usuarios y contraseñas conservan rutas, body y CSRF", async (context) => { + const calls: { url: unknown; options: RequestInit }[] = []; + context.mock.method(globalThis, "fetch", async (url: unknown, options: RequestInit) => { + calls.push({ url, options }); + return Response.json({ user: USER }); + }); + const body = { password: "example-password" }; + await identityApi.authenticate(false, body, CSRF_TOKEN); + await identityApi.authenticate(true, body, CSRF_TOKEN); + await identityApi.create(body, CSRF_TOKEN); + await identityApi.update(USER_ID, body, CSRF_TOKEN); + await identityApi.resetPassword(USER_ID, body, CSRF_TOKEN); + await identityApi.changePassword(body, CSRF_TOKEN); + assert.deepEqual( + calls.map((call) => call.url), + [ + "/api/auth/login", + "/api/auth/bootstrap", + "/api/users", + `/api/users/${USER_ID}/update`, + `/api/users/${USER_ID}/reset-password`, + "/api/auth/change-password", + ] + ); + for (const call of calls) { + assert.equal(call.options.method, "POST"); + assert.equal(new Headers(call.options.headers).get("x-csrf-token"), CSRF_TOKEN); + assert.equal(call.options.body, JSON.stringify(body)); + } +}); + +test("cerrar sesión espera la respuesta de la operación sin perder el token", async (context) => { + context.mock.method(globalThis, "fetch", async (url: unknown, options: RequestInit) => { + assert.equal(url, "/api/auth/logout"); + assert.equal(options.body, "{}"); + assert.equal(new Headers(options.headers).get("x-csrf-token"), CSRF_TOKEN); + return Response.json({ success: true }); + }); + assert.deepEqual(await logout(CSRF_TOKEN), { success: true }); +}); + +test("los errores HTTP no filtran detalles internos y los datos de sesión inválidos se rechazan", async (context) => { + const responses = [ + Response.json({ code: "FORBIDDEN", requestId: USER_ID }, { status: 403 }), + Response.json({ sql: "private" }, { status: 500 }), + Response.json({ user: null, setupRequired: false, csrfToken: "short" }), + ]; + context.mock.method(globalThis, "fetch", async () => { + const response = responses.shift(); + assert.ok(response); + return response; + }); + await assert.rejects( + getSession(), + (error: unknown) => error instanceof IdentityApiError && error.code === "FORBIDDEN" + ); + await assert.rejects( + getSession(), + (error: unknown) => + error instanceof IdentityApiError && + error.code === "INTERNAL_ERROR" && + !error.message.includes("private") + ); + await assert.rejects(getSession()); +}); diff --git a/modules/identity/src/web/shared/api/identity-api.ts b/modules/identity/src/web/shared/api/identity-api.ts index bd08f2a..d192020 100644 --- a/modules/identity/src/web/shared/api/identity-api.ts +++ b/modules/identity/src/web/shared/api/identity-api.ts @@ -1,53 +1,19 @@ -import type { z } from "zod/v4"; +import { changePassword } from "../../passwords/change/api/change-password.api"; +import { resetUserPassword } from "../../passwords/reset/api/reset-user-password.api"; +import { authenticate } from "../../sessions/shared/api/authenticate.api"; +import { getSession } from "../../sessions/shared/api/get-session.api"; +import { logout } from "../../sessions/shared/api/logout.api"; +import { createUser } from "../../users/create/api/create-user.api"; +import { listUsers } from "../../users/list/api/list-users.api"; +import { updateUser } from "../../users/update/api/update-user.api"; -import { - ActionResponseSchema, - IdentityErrorResponseSchema, - SessionResponseSchema, - UserResponseSchema, - UsersResponseSchema, -} from "../../../common"; - -export const identityKeys = { session: ["identity", "session"], users: ["identity", "users"] }; -export class IdentityApiError extends Error { - constructor(public readonly code: string) { - super(code); - } -} -export async function identityRequest( - path: string, - schema: z.ZodType, - body?: unknown, - csrfToken?: string -): Promise { - const response = await fetch(`/api${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 json: unknown = await response.json(); - if (!response.ok) { - const error = IdentityErrorResponseSchema.safeParse(json); - throw new IdentityApiError(error.success ? error.data.code : "INTERNAL_ERROR"); - } - return schema.parse(json); -} export const identityApi = { - session: () => identityRequest("/auth/session", SessionResponseSchema), - users: () => identityRequest("/users", UsersResponseSchema), - authenticate: (setup: boolean, body: unknown, token: string) => - identityRequest(setup ? "/auth/bootstrap" : "/auth/login", UserResponseSchema, body, token), - logout: (token: string) => identityRequest("/auth/logout", ActionResponseSchema, {}, token), - create: (body: unknown, token: string) => - identityRequest("/users", UserResponseSchema, body, token), - update: (id: string, body: unknown, token: string) => - identityRequest(`/users/${id}/update`, UserResponseSchema, body, token), - resetPassword: (id: string, body: unknown, token: string) => - identityRequest(`/users/${id}/reset-password`, UserResponseSchema, body, token), - changePassword: (body: unknown, token: string) => - identityRequest("/auth/change-password", UserResponseSchema, body, token), + session: getSession, + users: listUsers, + authenticate: authenticate, + logout: logout, + create: createUser, + update: updateUser, + resetPassword: resetUserPassword, + changePassword: changePassword, }; diff --git a/modules/identity/src/web/shared/api/identity-request.ts b/modules/identity/src/web/shared/api/identity-request.ts new file mode 100644 index 0000000..fae0d52 --- /dev/null +++ b/modules/identity/src/web/shared/api/identity-request.ts @@ -0,0 +1,28 @@ +import type { z } from "zod/v4"; + +import { IdentityErrorResponseSchema } from "../../../common"; + +import { IdentityApiError } from "./identity-api-error"; + +export async function identityRequest( + path: string, + schema: z.ZodType, + body?: unknown, + csrfToken?: string +): Promise { + const response = await fetch(`/api${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 json: unknown = await response.json(); + if (!response.ok) { + const error = IdentityErrorResponseSchema.safeParse(json); + throw new IdentityApiError(error.success ? error.data.code : "INTERNAL_ERROR"); + } + return schema.parse(json); +} diff --git a/modules/identity/src/web/shared/api/index.ts b/modules/identity/src/web/shared/api/index.ts index 7da326b..31c1842 100644 --- a/modules/identity/src/web/shared/api/index.ts +++ b/modules/identity/src/web/shared/api/index.ts @@ -1 +1,2 @@ -export { IdentityApiError, identityApi, identityKeys } from "./identity-api"; +export { identityApi } from "./identity-api"; +export { IdentityApiError } from "./identity-api-error"; diff --git a/modules/identity/src/web/shared/index.ts b/modules/identity/src/web/shared/index.ts index 3406df8..12af0f6 100644 --- a/modules/identity/src/web/shared/index.ts +++ b/modules/identity/src/web/shared/index.ts @@ -1,3 +1,4 @@ -export { identityApi, identityKeys } from "./api"; +export { identityApi } from "./api"; export { identityEnglish, identitySpanish } from "./i18n"; +export { identityKeys } from "./queries"; export { FormFeedback } from "./ui"; diff --git a/modules/identity/src/web/shared/queries/identity-query-keys.ts b/modules/identity/src/web/shared/queries/identity-query-keys.ts new file mode 100644 index 0000000..ffa3aaa --- /dev/null +++ b/modules/identity/src/web/shared/queries/identity-query-keys.ts @@ -0,0 +1 @@ +export const identityKeys = { session: ["identity", "session"], users: ["identity", "users"] }; diff --git a/modules/identity/src/web/shared/queries/index.ts b/modules/identity/src/web/shared/queries/index.ts new file mode 100644 index 0000000..88c1a3a --- /dev/null +++ b/modules/identity/src/web/shared/queries/index.ts @@ -0,0 +1 @@ +export { identityKeys } from "./identity-query-keys"; diff --git a/modules/identity/src/web/users/create/api/create-user.api.ts b/modules/identity/src/web/users/create/api/create-user.api.ts new file mode 100644 index 0000000..a99c803 --- /dev/null +++ b/modules/identity/src/web/users/create/api/create-user.api.ts @@ -0,0 +1,5 @@ +import { UserResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const createUser = (body: unknown, token: string) => + identityRequest("/users", UserResponseSchema, body, token); diff --git a/modules/identity/src/web/users/create/api/index.ts b/modules/identity/src/web/users/create/api/index.ts new file mode 100644 index 0000000..370a61c --- /dev/null +++ b/modules/identity/src/web/users/create/api/index.ts @@ -0,0 +1 @@ +export { createUser } from "./create-user.api"; diff --git a/modules/identity/src/web/users/list/api/index.ts b/modules/identity/src/web/users/list/api/index.ts new file mode 100644 index 0000000..8716311 --- /dev/null +++ b/modules/identity/src/web/users/list/api/index.ts @@ -0,0 +1 @@ +export { listUsers } from "./list-users.api"; diff --git a/modules/identity/src/web/users/list/api/list-users.api.ts b/modules/identity/src/web/users/list/api/list-users.api.ts new file mode 100644 index 0000000..68d8396 --- /dev/null +++ b/modules/identity/src/web/users/list/api/list-users.api.ts @@ -0,0 +1,4 @@ +import { UsersResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const listUsers = () => identityRequest("/users", UsersResponseSchema); diff --git a/modules/identity/src/web/users/list/controllers/index.ts b/modules/identity/src/web/users/list/controllers/index.ts new file mode 100644 index 0000000..d5880f2 --- /dev/null +++ b/modules/identity/src/web/users/list/controllers/index.ts @@ -0,0 +1 @@ +export { useUserManagementController } from "./use-user-management-controller"; diff --git a/modules/identity/src/web/users/list/controllers/use-user-management-controller.ts b/modules/identity/src/web/users/list/controllers/use-user-management-controller.ts new file mode 100644 index 0000000..1772051 --- /dev/null +++ b/modules/identity/src/web/users/list/controllers/use-user-management-controller.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { UserDTO } from "../../../../common"; +import { identityKeys } from "../../../shared/queries"; +import { listUsers } from "../api"; + +export function useUserManagementController() { + const users = useQuery({ queryKey: identityKeys.users, queryFn: listUsers }); + const [createOpen, setCreateOpen] = useState(false); + const [selection, setSelection] = useState< + | { + id: string; + action: "edit" | "password"; + } + | undefined + >(); + const selected: UserDTO | undefined = users.data?.users.find((user) => user.id === selection?.id); + return { users, createOpen, setCreateOpen, selection, setSelection, selected }; +} diff --git a/modules/identity/src/web/users/list/types/index.ts b/modules/identity/src/web/users/list/types/index.ts new file mode 100644 index 0000000..880d3f0 --- /dev/null +++ b/modules/identity/src/web/users/list/types/index.ts @@ -0,0 +1 @@ +export type { UserManagementProps } from "./user-management-props"; diff --git a/modules/identity/src/web/users/list/types/user-management-props.ts b/modules/identity/src/web/users/list/types/user-management-props.ts new file mode 100644 index 0000000..113dd75 --- /dev/null +++ b/modules/identity/src/web/users/list/types/user-management-props.ts @@ -0,0 +1,3 @@ +export type UserManagementProps = { + csrfToken: string; +}; diff --git a/modules/identity/src/web/users/list/ui/user-management.tsx b/modules/identity/src/web/users/list/ui/user-management.tsx index e37b0b7..d6e76d6 100644 --- a/modules/identity/src/web/users/list/ui/user-management.tsx +++ b/modules/identity/src/web/users/list/ui/user-management.tsx @@ -6,22 +6,18 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { UserDTO } from "../../../../common"; -import { FormFeedback, identityApi, identityKeys } from "../../../shared"; +import { FormFeedback } from "../../../shared"; import { UserEditorDialog } from "../../shared"; +import { useUserManagementController } from "../controllers"; +import type { UserManagementProps } from "../types"; -export function UserManagement({ csrfToken }: { csrfToken: string }) { +export function UserManagement(props: UserManagementProps) { + const { csrfToken } = props; + const { users, createOpen, setCreateOpen, selection, setSelection, selected } = + useUserManagementController(); const { t, i18n } = useTranslation("identity"); - const users = useQuery({ queryKey: identityKeys.users, queryFn: identityApi.users }); - const [createOpen, setCreateOpen] = useState(false); - const [selection, setSelection] = useState< - { id: string; action: "edit" | "password" } | undefined - >(); - const selected: UserDTO | undefined = users.data?.users.find((user) => user.id === selection?.id); return (
({ + resolver: zodResolver(user ? UserFormSchema.extend({ password: z.string() }) : UserFormSchema), + defaultValues: { + name: user?.name ?? "", + email: user?.email ?? "", + active: user?.active ?? true, + password: "", + }, + }); + const mutation = useMutation({ + mutationFn: (values: UserFormValues) => + user + ? updateUser( + user.id, + UpdateUserRequestSchema.parse({ + name: values.name, + email: values.email, + active: values.active, + }), + csrfToken + ) + : createUser( + CreateUserRequestSchema.parse({ + name: values.name, + email: values.email, + password: values.password, + }), + csrfToken + ), + onSuccess: async () => { + if (!user) { + form.reset(); + } + await Promise.all([ + client.invalidateQueries({ queryKey: identityKeys.users }), + client.invalidateQueries({ queryKey: identityKeys.session }), + ]); + onSaved?.(); + }, + }); + const submit = form.handleSubmit((values) => mutation.mutate(values)); + return { form, mutation, submit }; +} diff --git a/modules/identity/src/web/users/shared/schemas/index.ts b/modules/identity/src/web/users/shared/schemas/index.ts new file mode 100644 index 0000000..041051f --- /dev/null +++ b/modules/identity/src/web/users/shared/schemas/index.ts @@ -0,0 +1 @@ +export { UserFormSchema } from "./user-form.schema"; diff --git a/modules/identity/src/web/users/shared/schemas/user-form.schema.ts b/modules/identity/src/web/users/shared/schemas/user-form.schema.ts new file mode 100644 index 0000000..3e97485 --- /dev/null +++ b/modules/identity/src/web/users/shared/schemas/user-form.schema.ts @@ -0,0 +1,5 @@ +import { z } from "zod/v4"; + +import { CreateUserRequestSchema } from "../../../../common"; + +export const UserFormSchema = CreateUserRequestSchema.extend({ active: z.boolean() }); diff --git a/modules/identity/src/web/users/shared/types/index.ts b/modules/identity/src/web/users/shared/types/index.ts new file mode 100644 index 0000000..22ab69a --- /dev/null +++ b/modules/identity/src/web/users/shared/types/index.ts @@ -0,0 +1 @@ +export type { UserFormProps, UserFormValues } from "./user-form-props"; diff --git a/modules/identity/src/web/users/shared/types/user-form-props.ts b/modules/identity/src/web/users/shared/types/user-form-props.ts new file mode 100644 index 0000000..f55f417 --- /dev/null +++ b/modules/identity/src/web/users/shared/types/user-form-props.ts @@ -0,0 +1,13 @@ +import type { z } from "zod/v4"; + +import type { UserDTO } from "../../../../common"; +import type { UserFormSchema } from "../schemas"; + +export type UserFormValues = z.infer; + +export type UserFormProps = { + user?: UserDTO; + csrfToken: string; + onSaved?: () => void; + showTitle?: boolean; +}; diff --git a/modules/identity/src/web/users/shared/ui/user-form.tsx b/modules/identity/src/web/users/shared/ui/user-form.tsx index e7123d4..c034ffb 100644 --- a/modules/identity/src/web/users/shared/ui/user-form.tsx +++ b/modules/identity/src/web/users/shared/ui/user-form.tsx @@ -1,77 +1,22 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { UserDTO } from "../../../../common"; -import { CreateUserRequestSchema, UpdateUserRequestSchema } from "../../../../common"; -import { FormFeedback, identityApi, identityKeys } from "../../../shared"; +import { FormFeedback } from "../../../shared"; +import { useUserFormController } from "../controllers"; +import type { UserFormProps } from "../types"; -const UserFormSchema = CreateUserRequestSchema.extend({ active: z.boolean() }); -type UserFormValues = z.infer; -export function UserForm({ - user, - csrfToken, - onSaved, - showTitle = true, -}: { - user?: UserDTO; - csrfToken: string; - onSaved?: () => void; - showTitle?: boolean; -}) { +export function UserForm(props: UserFormProps) { + const { user, showTitle = true } = props; + const { form, mutation, submit } = useUserFormController(props); const { t } = useTranslation("identity"); const id = useId(); - const client = useQueryClient(); - const form = useForm({ - resolver: zodResolver(user ? UserFormSchema.extend({ password: z.string() }) : UserFormSchema), - defaultValues: { - name: user?.name ?? "", - email: user?.email ?? "", - active: user?.active ?? true, - password: "", - }, - }); - const mutation = useMutation({ - mutationFn: (values: UserFormValues) => - user - ? identityApi.update( - user.id, - UpdateUserRequestSchema.parse({ - name: values.name, - email: values.email, - active: values.active, - }), - csrfToken - ) - : identityApi.create( - CreateUserRequestSchema.parse({ - name: values.name, - email: values.email, - password: values.password, - }), - csrfToken - ), - onSuccess: async () => { - if (!user) { - form.reset(); - } - await Promise.all([ - client.invalidateQueries({ queryKey: identityKeys.users }), - client.invalidateQueries({ queryKey: identityKeys.session }), - ]); - onSaved?.(); - }, - }); return ( - mutation.mutate(values))}> + {showTitle &&

{t(user ? "editUser" : "createUser")}

}
diff --git a/modules/identity/src/web/users/update/api/index.ts b/modules/identity/src/web/users/update/api/index.ts new file mode 100644 index 0000000..34de7de --- /dev/null +++ b/modules/identity/src/web/users/update/api/index.ts @@ -0,0 +1 @@ +export { updateUser } from "./update-user.api"; diff --git a/modules/identity/src/web/users/update/api/update-user.api.ts b/modules/identity/src/web/users/update/api/update-user.api.ts new file mode 100644 index 0000000..2a65aa9 --- /dev/null +++ b/modules/identity/src/web/users/update/api/update-user.api.ts @@ -0,0 +1,5 @@ +import { UserResponseSchema } from "../../../../common"; +import { identityRequest } from "../../../shared/api/identity-request"; + +export const updateUser = (id: string, body: unknown, token: string) => + identityRequest(`/users/${id}/update`, UserResponseSchema, body, token); diff --git a/modules/identity/src/web/workspace/view/controllers/index.ts b/modules/identity/src/web/workspace/view/controllers/index.ts new file mode 100644 index 0000000..5e2f83b --- /dev/null +++ b/modules/identity/src/web/workspace/view/controllers/index.ts @@ -0,0 +1,2 @@ +export { useAuthenticatedWorkspaceController } from "./use-authenticated-workspace-controller"; +export { useIdentityWorkspaceController } from "./use-identity-workspace-controller"; diff --git a/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts b/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts new file mode 100644 index 0000000..a2add99 --- /dev/null +++ b/modules/identity/src/web/workspace/view/controllers/use-authenticated-workspace-controller.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; + +import { logout as logoutSession } from "../../../sessions/shared/api"; +import { identityKeys } from "../../../shared/queries"; +import type { AuthenticatedWorkspaceProps } from "../types"; + +export function useAuthenticatedWorkspaceController({ snapshot }: AuthenticatedWorkspaceProps) { + const client = useQueryClient(); + const [page, setPage] = useState("home"); + const logout = useMutation({ + mutationFn: () => logoutSession(snapshot.csrfToken), + onSuccess: async () => { + setPage("home"); + client.removeQueries({ + predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session", + }); + await client.invalidateQueries({ queryKey: identityKeys.session }); + }, + }); + return { page, setPage, logout }; +} diff --git a/modules/identity/src/web/workspace/view/controllers/use-identity-workspace-controller.ts b/modules/identity/src/web/workspace/view/controllers/use-identity-workspace-controller.ts new file mode 100644 index 0000000..dc4254e --- /dev/null +++ b/modules/identity/src/web/workspace/view/controllers/use-identity-workspace-controller.ts @@ -0,0 +1,13 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getSession } from "../../../sessions/shared/api"; +import { identityKeys } from "../../../shared/queries"; + +export function useIdentityWorkspaceController() { + const session = useQuery({ + queryKey: identityKeys.session, + queryFn: getSession, + retry: false, + }); + return { session }; +} diff --git a/modules/identity/src/web/workspace/view/types/authenticated-workspace-props.ts b/modules/identity/src/web/workspace/view/types/authenticated-workspace-props.ts new file mode 100644 index 0000000..7f2a1e8 --- /dev/null +++ b/modules/identity/src/web/workspace/view/types/authenticated-workspace-props.ts @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import type { SessionResponseDTO } from "../../../../common"; +import type { WorkspacePage, WorkspaceRenderer } from "../../shared/types"; + +export type AuthenticatedWorkspaceProps = { + snapshot: SessionResponseDTO; + children: ReactNode; + pages: readonly WorkspacePage[]; + renderWorkspace?: WorkspaceRenderer | undefined; +}; diff --git a/modules/identity/src/web/workspace/view/types/identity-workspace-props.ts b/modules/identity/src/web/workspace/view/types/identity-workspace-props.ts new file mode 100644 index 0000000..e10bd2f --- /dev/null +++ b/modules/identity/src/web/workspace/view/types/identity-workspace-props.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from "react"; + +import type { WorkspacePage, WorkspaceRenderer } from "../../shared/types"; + +export type IdentityWorkspaceProps = { + children: ReactNode; + pages?: readonly WorkspacePage[]; + renderWorkspace?: WorkspaceRenderer | undefined; +}; diff --git a/modules/identity/src/web/workspace/view/types/index.ts b/modules/identity/src/web/workspace/view/types/index.ts new file mode 100644 index 0000000..978522f --- /dev/null +++ b/modules/identity/src/web/workspace/view/types/index.ts @@ -0,0 +1,2 @@ +export type { AuthenticatedWorkspaceProps } from "./authenticated-workspace-props"; +export type { IdentityWorkspaceProps } from "./identity-workspace-props"; diff --git a/modules/identity/src/web/workspace/view/ui/authenticated-workspace.tsx b/modules/identity/src/web/workspace/view/ui/authenticated-workspace.tsx index 6d70ec6..b990826 100644 --- a/modules/identity/src/web/workspace/view/ui/authenticated-workspace.tsx +++ b/modules/identity/src/web/workspace/view/ui/authenticated-workspace.tsx @@ -1,42 +1,22 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { Card, CardContent } from "@repo/shadcn-ui/components/card"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { SessionResponseDTO } from "../../../../common"; import { PasswordForm } from "../../../passwords"; -import { FormFeedback, identityApi, identityKeys } from "../../../shared"; +import { FormFeedback } from "../../../shared"; import { UserManagement } from "../../../users"; -import type { WorkspacePage, WorkspaceRenderer } from "../../shared"; +import { useAuthenticatedWorkspaceController } from "../controllers"; +import type { AuthenticatedWorkspaceProps } from "../types"; import { CustomWorkspace } from "./custom-workspace"; -export function AuthenticatedWorkspace({ - snapshot, - children, - pages, - renderWorkspace, -}: { - snapshot: SessionResponseDTO; - children: ReactNode; - pages: readonly WorkspacePage[]; - renderWorkspace?: WorkspaceRenderer | undefined; -}) { +function isHomePage(page: string, isInitialAdmin: boolean) { + return page === "home" || (page === "users" && !isInitialAdmin); +} +export function AuthenticatedWorkspace(props: AuthenticatedWorkspaceProps) { + const { snapshot, children, pages, renderWorkspace } = props; + const { page, setPage, logout } = useAuthenticatedWorkspaceController(props); const { t } = useTranslation("identity"); - const client = useQueryClient(); - const [page, setPage] = useState("home"); - const logout = useMutation({ - mutationFn: () => identityApi.logout(snapshot.csrfToken), - onSuccess: async () => { - setPage("home"); - client.removeQueries({ - predicate: (query) => query.queryKey[0] !== "identity" || query.queryKey[1] !== "session", - }); - await client.invalidateQueries({ queryKey: identityKeys.session }); - }, - }); if (!snapshot.user) { return null; } @@ -114,7 +94,3 @@ export function AuthenticatedWorkspace({ ); } - -function isHomePage(page: string, isInitialAdmin: boolean) { - return page === "home" || (page === "users" && !isInitialAdmin); -} diff --git a/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx b/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx index abac810..f5717f8 100644 --- a/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx +++ b/modules/identity/src/web/workspace/view/ui/custom-workspace.tsx @@ -5,7 +5,7 @@ import type { SessionResponseDTO } from "../../../../common"; import { AccountPasswordDialog } from "../../../passwords"; import { FormFeedback } from "../../../shared"; import { UserManagementDialog } from "../../../users"; -import type { WorkspaceRenderer } from "../../shared"; +import type { WorkspaceRenderer } from "../../shared/types"; export function CustomWorkspace({ snapshot, diff --git a/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx b/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx index e4d855d..788755a 100644 --- a/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx +++ b/modules/identity/src/web/workspace/view/ui/identity-workspace.tsx @@ -6,33 +6,22 @@ import { CardHeader, CardTitle, } from "@repo/shadcn-ui/components/card"; -import { useQuery } from "@tanstack/react-query"; -import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { CredentialForm } from "../../../sessions"; -import { FormFeedback, identityApi, identityKeys } from "../../../shared"; -import type { WorkspacePage, WorkspaceRenderer } from "../../shared"; +import { FormFeedback } from "../../../shared"; +import type { WorkspacePage } from "../../shared/types"; +import { useIdentityWorkspaceController } from "../controllers"; +import type { IdentityWorkspaceProps } from "../types"; import { AuthenticatedWorkspace } from "./authenticated-workspace"; const EMPTY_PAGES: readonly WorkspacePage[] = []; -export function IdentityWorkspace({ - children, - pages = EMPTY_PAGES, - renderWorkspace, -}: { - children: ReactNode; - pages?: readonly WorkspacePage[]; - renderWorkspace?: WorkspaceRenderer | undefined; -}) { +export function IdentityWorkspace(props: IdentityWorkspaceProps) { + const { children, pages = EMPTY_PAGES, renderWorkspace } = props; + const { session } = useIdentityWorkspaceController(); const { t } = useTranslation("identity"); - const session = useQuery({ - queryKey: identityKeys.session, - queryFn: identityApi.session, - retry: false, - }); if (session.isPending) { return (

diff --git a/modules/resources/package.json b/modules/resources/package.json index eecc4cb..b597f31 100644 --- a/modules/resources/package.json +++ b/modules/resources/package.json @@ -11,7 +11,7 @@ }, "scripts": { "typecheck": "tsc -p tsconfig.json", - "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx" + "test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts" }, "dependencies": { "@repo/rdx-ddd": "workspace:*", diff --git a/modules/resources/src/web/catalog-fields/shared/controllers/index.ts b/modules/resources/src/web/catalog-fields/shared/controllers/index.ts new file mode 100644 index 0000000..6775829 --- /dev/null +++ b/modules/resources/src/web/catalog-fields/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useCatalogFieldFormController } from "./use-catalog-form-controller"; diff --git a/modules/resources/src/web/catalog-fields/shared/controllers/use-catalog-form-controller.ts b/modules/resources/src/web/catalog-fields/shared/controllers/use-catalog-form-controller.ts new file mode 100644 index 0000000..08bbda4 --- /dev/null +++ b/modules/resources/src/web/catalog-fields/shared/controllers/use-catalog-form-controller.ts @@ -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({ + 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 }; +} diff --git a/modules/resources/src/web/catalog-fields/shared/schemas/catalog-form.schema.ts b/modules/resources/src/web/catalog-fields/shared/schemas/catalog-form.schema.ts new file mode 100644 index 0000000..cd0f053 --- /dev/null +++ b/modules/resources/src/web/catalog-fields/shared/schemas/catalog-form.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +import { ResourceDefinitionSchema } from "../../../../common"; + +export const FieldFormSchema = ResourceDefinitionSchema.options[0].omit({ choices: true }).extend({ + choicesText: z.string().max(10000), +}); diff --git a/modules/resources/src/web/catalog-fields/shared/schemas/index.ts b/modules/resources/src/web/catalog-fields/shared/schemas/index.ts new file mode 100644 index 0000000..666d485 --- /dev/null +++ b/modules/resources/src/web/catalog-fields/shared/schemas/index.ts @@ -0,0 +1 @@ +export { FieldFormSchema } from "./catalog-form.schema"; diff --git a/modules/resources/src/web/catalog-fields/shared/types/catalog-form-props.ts b/modules/resources/src/web/catalog-fields/shared/types/catalog-form-props.ts new file mode 100644 index 0000000..840378b --- /dev/null +++ b/modules/resources/src/web/catalog-fields/shared/types/catalog-form-props.ts @@ -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; + +export type CatalogFieldFormProps = { + scope: string; + csrfToken: string; + item?: CatalogItemDTO | undefined; + onSaved: CatalogSaved; +}; diff --git a/modules/resources/src/web/catalog-fields/shared/types/index.ts b/modules/resources/src/web/catalog-fields/shared/types/index.ts new file mode 100644 index 0000000..9554795 --- /dev/null +++ b/modules/resources/src/web/catalog-fields/shared/types/index.ts @@ -0,0 +1 @@ +export type { CatalogFieldFormProps, FieldFormValues } from "./catalog-form-props"; diff --git a/modules/resources/src/web/catalog-fields/shared/ui/catalog-form.tsx b/modules/resources/src/web/catalog-fields/shared/ui/catalog-form.tsx index c1801f8..7d66764 100644 --- a/modules/resources/src/web/catalog-fields/shared/ui/catalog-form.tsx +++ b/modules/resources/src/web/catalog-fields/shared/ui/catalog-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; import { Input } from "@repo/shadcn-ui/components/input"; @@ -6,66 +5,21 @@ import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; import { useId } from "react"; -import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { z } from "zod/v4"; -import type { CatalogItemDTO } from "../../../../common"; -import { FieldTypeSchema, ResourceDefinitionSchema } from "../../../../common"; -import type { CatalogSaved } from "../../../catalog/shared/controllers"; -import { useCatalogSave } from "../../../catalog/shared/controllers"; +import { FieldTypeSchema } from "../../../../common"; import { CatalogError } from "../../../catalog/shared/ui/catalog-error"; import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help"; +import { useCatalogFieldFormController } from "../controllers"; +import type { CatalogFieldFormProps } from "../types"; -const FieldFormSchema = ResourceDefinitionSchema.options[0].omit({ choices: true }).extend({ - choicesText: z.string().max(10000), -}); - -type FieldFormValues = z.infer; - -export function CatalogFieldForm({ - scope, - csrfToken, - item, - onSaved, -}: { - scope: string; - csrfToken: string; - item?: CatalogItemDTO | undefined; - onSaved: CatalogSaved; -}) { +export function CatalogFieldForm(props: CatalogFieldFormProps) { + const { scope, item } = props; + const { form, mutation, type, submit } = useCatalogFieldFormController(props); const { t } = useTranslation("resources"); const id = useId(); - const current = item?.values.kind === "field" ? item.values : undefined; - const form = useForm({ - 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 ( - - mutation.mutate({ - ...values, - choices: - values.type === "choice" - ? choicesText - .split(/\r?\n/) - .map((value) => value.trim()) - .filter(Boolean) - : [], - }) - )} - > +

diff --git a/modules/resources/src/web/catalog-templates/shared/controllers/index.ts b/modules/resources/src/web/catalog-templates/shared/controllers/index.ts new file mode 100644 index 0000000..420deb7 --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/controllers/index.ts @@ -0,0 +1 @@ +export { useCatalogTemplateFormController } from "./use-catalog-template-form-controller"; diff --git a/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts b/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts new file mode 100644 index 0000000..83cc7c8 --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/controllers/use-catalog-template-form-controller.ts @@ -0,0 +1,46 @@ +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 { templateUsage } from "../helpers"; +import { TemplateFormSchema } from "../schemas"; +import type { CatalogTemplateFormProps, TemplateFormValues } from "../types"; + +export function useCatalogTemplateFormController({ + scope, + csrfToken, + item, + copy, + items, + onSaved, +}: CatalogTemplateFormProps) { + const { t } = useTranslation("resources"); + const [creatingField, setCreatingField] = useState(false); + const source = item ?? copy; + const current = source?.values.kind === "template" ? source.values : undefined; + const form = useForm({ + resolver: zodResolver(TemplateFormSchema), + defaultValues: { + kind: "template", + name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""), + description: current?.description ?? "", + usage: templateUsage(source), + 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 }; +} diff --git a/modules/resources/src/web/catalog-templates/shared/helpers/catalog-template-form-values.ts b/modules/resources/src/web/catalog-templates/shared/helpers/catalog-template-form-values.ts new file mode 100644 index 0000000..8a557b4 --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/helpers/catalog-template-form-values.ts @@ -0,0 +1,11 @@ +import type { CatalogItemDTO } from "../../../../common"; + +export function templateUsage(source: CatalogItemDTO | undefined) { + if (source?.builtinKey === "template:application") { + return "application"; + } + if (source?.builtinKey === "template:certificate") { + return "certificate"; + } + return source?.values.kind === "template" ? source.values.usage : undefined; +} diff --git a/modules/resources/src/web/catalog-templates/shared/helpers/index.ts b/modules/resources/src/web/catalog-templates/shared/helpers/index.ts new file mode 100644 index 0000000..141bb7c --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/helpers/index.ts @@ -0,0 +1 @@ +export { templateUsage } from "./catalog-template-form-values"; diff --git a/modules/resources/src/web/catalog-templates/shared/schemas/catalog-template-form.schema.ts b/modules/resources/src/web/catalog-templates/shared/schemas/catalog-template-form.schema.ts new file mode 100644 index 0000000..911d95f --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/schemas/catalog-template-form.schema.ts @@ -0,0 +1,3 @@ +import { ResourceDefinitionSchema } from "../../../../common"; + +export const TemplateFormSchema = ResourceDefinitionSchema.options[1]; diff --git a/modules/resources/src/web/catalog-templates/shared/schemas/index.ts b/modules/resources/src/web/catalog-templates/shared/schemas/index.ts new file mode 100644 index 0000000..8f0a8e1 --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/schemas/index.ts @@ -0,0 +1 @@ +export { TemplateFormSchema } from "./catalog-template-form.schema"; diff --git a/modules/resources/src/web/catalog-templates/shared/types/catalog-template-form-props.ts b/modules/resources/src/web/catalog-templates/shared/types/catalog-template-form-props.ts new file mode 100644 index 0000000..f009f9e --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/types/catalog-template-form-props.ts @@ -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; + +export type CatalogTemplateFormProps = { + scope: string; + csrfToken: string; + item?: CatalogItemDTO | undefined; + copy?: CatalogItemDTO | undefined; + items: readonly CatalogItemDTO[]; + onSaved: CatalogSaved; + allowQuickCreate?: boolean; +}; diff --git a/modules/resources/src/web/catalog-templates/shared/types/index.ts b/modules/resources/src/web/catalog-templates/shared/types/index.ts new file mode 100644 index 0000000..d7f9b03 --- /dev/null +++ b/modules/resources/src/web/catalog-templates/shared/types/index.ts @@ -0,0 +1 @@ +export type { CatalogTemplateFormProps, TemplateFormValues } from "./catalog-template-form-props"; diff --git a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx index 3c18558..5287ad8 100644 --- a/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx +++ b/modules/resources/src/web/catalog-templates/shared/ui/catalog-template-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { Checkbox } from "@repo/shadcn-ui/components/checkbox"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -6,67 +5,24 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useId, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { z } from "zod/v4"; -import type { CatalogItemDTO } from "../../../../common"; -import { ResourceDefinitionSchema } from "../../../../common"; -import type { CatalogSaved } from "../../../catalog/shared/controllers"; -import { useCatalogSave } from "../../../catalog/shared/controllers"; import { CatalogError } from "../../../catalog/shared/ui/catalog-error"; import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help"; import { CatalogFieldCreateDialog } from "../../../catalog-fields"; +import { useCatalogTemplateFormController } from "../controllers"; +import type { CatalogTemplateFormProps } from "../types"; -const TemplateFormSchema = ResourceDefinitionSchema.options[1]; -type TemplateFormValues = z.infer; -export function CatalogTemplateForm({ - scope, - csrfToken, - item, - copy, - items, - onSaved, - allowQuickCreate = true, -}: { - scope: string; - csrfToken: string; - item?: CatalogItemDTO | undefined; - copy?: CatalogItemDTO | undefined; - items: readonly CatalogItemDTO[]; - onSaved: CatalogSaved; - allowQuickCreate?: boolean; -}) { +export function CatalogTemplateForm(props: CatalogTemplateFormProps) { + const { scope, csrfToken, item, allowQuickCreate = true } = props; + const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } = + useCatalogTemplateFormController(props); const { t } = useTranslation("resources"); const id = useId(); - const [creatingField, setCreatingField] = useState(false); - const source = item ?? copy; - const current = source?.values.kind === "template" ? source.values : undefined; - const form = useForm({ - resolver: zodResolver(TemplateFormSchema), - defaultValues: { - kind: "template", - name: copy ? t("copyName", { name: copy.values.name }) : (current?.name ?? ""), - description: current?.description ?? "", - usage: templateUsage(source), - 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 ( <> - mutation.mutate(values))}> +
@@ -212,13 +168,3 @@ export function CatalogTemplateForm({ ); } - -function templateUsage(source: CatalogItemDTO | undefined) { - if (source?.builtinKey === "template:application") { - return "application"; - } - if (source?.builtinKey === "template:certificate") { - return "certificate"; - } - return source?.values.kind === "template" ? source.values.usage : undefined; -} diff --git a/modules/resources/src/web/catalog/index.ts b/modules/resources/src/web/catalog/index.ts index d9b9c94..3d913d2 100644 --- a/modules/resources/src/web/catalog/index.ts +++ b/modules/resources/src/web/catalog/index.ts @@ -1,2 +1,2 @@ export { ResourceCatalogPage } from "./list"; -export { catalogApi, catalogKeys } from "./shared"; +export { catalogApi, catalogKeys } from "./shared/api"; diff --git a/modules/resources/src/web/catalog/list/controllers/index.ts b/modules/resources/src/web/catalog/list/controllers/index.ts new file mode 100644 index 0000000..bcc471e --- /dev/null +++ b/modules/resources/src/web/catalog/list/controllers/index.ts @@ -0,0 +1 @@ +export { useResourceCatalogPageController } from "./use-catalog-page-controller"; diff --git a/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts b/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts new file mode 100644 index 0000000..eed98c1 --- /dev/null +++ b/modules/resources/src/web/catalog/list/controllers/use-catalog-page-controller.ts @@ -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 { ResourceCatalogPageProps } from "../types"; + +export function useResourceCatalogPageController({ scope, csrfToken }: ResourceCatalogPageProps) { + const client = useQueryClient(); + const [tab, setTab] = useState<"field" | "template">("template"); + const [search, setSearch] = useState(""); + const [viewId, setViewId] = useState(); + const [editor, setEditor] = useState(); + 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: ["resource-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, + }; +} diff --git a/modules/resources/src/web/catalog/list/types/catalog-page-props.ts b/modules/resources/src/web/catalog/list/types/catalog-page-props.ts new file mode 100644 index 0000000..64cffd2 --- /dev/null +++ b/modules/resources/src/web/catalog/list/types/catalog-page-props.ts @@ -0,0 +1,4 @@ +export type ResourceCatalogPageProps = { + scope: string; + csrfToken: string; +}; diff --git a/modules/resources/src/web/catalog/list/types/index.ts b/modules/resources/src/web/catalog/list/types/index.ts new file mode 100644 index 0000000..f905085 --- /dev/null +++ b/modules/resources/src/web/catalog/list/types/index.ts @@ -0,0 +1 @@ +export type { ResourceCatalogPageProps } from "./catalog-page-props"; diff --git a/modules/resources/src/web/catalog/list/ui/catalog-page.tsx b/modules/resources/src/web/catalog/list/ui/catalog-page.tsx index a3fb59f..6bd8b74 100644 --- a/modules/resources/src/web/catalog/list/ui/catalog-page.tsx +++ b/modules/resources/src/web/catalog/list/ui/catalog-page.tsx @@ -1,42 +1,31 @@ import { Button } from "@repo/shadcn-ui/components/button"; import { EntityListHeader, EntityTable } from "@repo/shadcn-ui/components/entity-list"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { CatalogItemDTO } from "../../../../common"; import { CatalogDefinitionDialog } from "../../detail"; -import type { CatalogEditor } from "../../shared"; -import { CatalogEditorDialog, CatalogError, catalogApi, catalogKeys } from "../../shared"; +import { CatalogEditorDialog, CatalogError } from "../../shared"; +import { useResourceCatalogPageController } from "../controllers"; +import type { ResourceCatalogPageProps } from "../types"; import { CatalogCell } from "./catalog-cell"; -export function ResourceCatalogPage({ scope, csrfToken }: { scope: string; csrfToken: string }) { +export function ResourceCatalogPage(props: ResourceCatalogPageProps) { + const { scope, csrfToken } = props; + const { + tab, + setTab, + search, + setSearch, + setViewId, + editor, + setEditor, + query, + mutation, + items, + view, + visible, + } = useResourceCatalogPageController(props); const { t, i18n } = useTranslation("resources"); - const client = useQueryClient(); - const [tab, setTab] = useState<"field" | "template">("template"); - const [search, setSearch] = useState(""); - const [viewId, setViewId] = useState(); - const [editor, setEditor] = useState(); - 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: ["resource-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 (
item.id === id)?.name ?? id) : missing; +} diff --git a/modules/resources/src/web/resources/detail/ui/integration-summary.tsx b/modules/resources/src/web/resources/detail/ui/integration-summary.tsx index 97e1d1d..e3bb7ab 100644 --- a/modules/resources/src/web/resources/detail/ui/integration-summary.tsx +++ b/modules/resources/src/web/resources/detail/ui/integration-summary.tsx @@ -1,6 +1,7 @@ import { useTranslation } from "react-i18next"; import type { ResourceInput, ResourceReferenceDTO } from "../../../../common/inventory"; +import { referenceName } from "../helpers"; export function IntegrationSummary({ integration, @@ -37,11 +38,3 @@ export function IntegrationSummary({ ); } - -export function referenceName( - id: string | null, - references: readonly ResourceReferenceDTO[], - missing: string -) { - return id ? (references.find((item) => item.id === id)?.name ?? id) : missing; -} diff --git a/modules/resources/src/web/resources/index.ts b/modules/resources/src/web/resources/index.ts index 4e68f19..c7c229c 100644 --- a/modules/resources/src/web/resources/index.ts +++ b/modules/resources/src/web/resources/index.ts @@ -1,2 +1,3 @@ export { ResourcesPage } from "./list"; -export { ResourceEditor, inventoryApi, inventoryKeys } from "./shared"; +export { ResourceEditor } from "./shared"; +export { inventoryApi, inventoryKeys } from "./shared/api"; diff --git a/modules/resources/src/web/resources/list/controllers/index.ts b/modules/resources/src/web/resources/list/controllers/index.ts new file mode 100644 index 0000000..087fe4f --- /dev/null +++ b/modules/resources/src/web/resources/list/controllers/index.ts @@ -0,0 +1 @@ +export { useResourcesPageController } from "./use-resources-page-controller"; diff --git a/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts new file mode 100644 index 0000000..d3776d8 --- /dev/null +++ b/modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts @@ -0,0 +1,47 @@ +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; +import { inventoryApi, inventoryKeys } from "../../shared/api"; +import type { ResourcesPageProps } from "../types"; + +function initialEditor(create: boolean) { + return create ? "new" : undefined; +} +export function useResourcesPageController({ + companyId, + initialId, + initialCreate = false, +}: ResourcesPageProps) { + const [search, setSearch] = useState(""); + const [editor, setEditor] = useState(() => initialEditor(initialCreate)); + const [viewId, setViewId] = useState(initialId); + const query = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => inventoryApi.get(companyId), + }); + const catalog = useQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const edited = query.data?.resources.find((item) => item.id === editor); + const viewed = query.data?.resources.find((item) => item.id === viewId); + const visible = + query.data?.resources.filter((item) => + `${item.values.name} ${item.specification.templateName}` + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()) + ) ?? []; + return { + search, + setSearch, + editor, + setEditor, + setViewId, + query, + catalog, + edited, + viewed, + visible, + }; +} diff --git a/modules/resources/src/web/resources/list/types/index.ts b/modules/resources/src/web/resources/list/types/index.ts new file mode 100644 index 0000000..854996f --- /dev/null +++ b/modules/resources/src/web/resources/list/types/index.ts @@ -0,0 +1 @@ +export type { ResourcesPageProps } from "./resources-page-props"; diff --git a/modules/resources/src/web/resources/list/types/resources-page-props.ts b/modules/resources/src/web/resources/list/types/resources-page-props.ts new file mode 100644 index 0000000..4f648f5 --- /dev/null +++ b/modules/resources/src/web/resources/list/types/resources-page-props.ts @@ -0,0 +1,11 @@ +import type { ReactNode } from "react"; + +import type { ResourceDTO } from "../../../../common"; + +export type ResourcesPageProps = { + companyId: string; + csrfToken: string; + relatedContent?: (resource: ResourceDTO) => ReactNode; + initialId?: string | undefined; + initialCreate?: boolean | undefined; +}; diff --git a/modules/resources/src/web/resources/list/ui/resources-page.tsx b/modules/resources/src/web/resources/list/ui/resources-page.tsx index 83a7a52..e6219f5 100644 --- a/modules/resources/src/web/resources/list/ui/resources-page.tsx +++ b/modules/resources/src/web/resources/list/ui/resources-page.tsx @@ -6,50 +6,29 @@ import { EntityStatus, EntityTable, } from "@repo/shadcn-ui/components/entity-list"; -import { useQuery } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { useState } from "react"; import { useTranslation } from "react-i18next"; -import type { ResourceDTO } from "../../../../common"; -import { catalogApi, catalogKeys } from "../../../catalog"; import { InventoryFeedback } from "../../../shared"; import { ResourceDetailDialog } from "../../detail"; -import { ResourceEditorDialog, inventoryApi, inventoryKeys } from "../../shared"; +import { ResourceEditorDialog } from "../../shared"; +import { useResourcesPageController } from "../controllers"; +import type { ResourcesPageProps } from "../types"; -export function ResourcesPage({ - companyId, - csrfToken, - relatedContent, - initialId, - initialCreate = false, -}: { - companyId: string; - csrfToken: string; - relatedContent?: (resource: ResourceDTO) => ReactNode; - initialId?: string | undefined; - initialCreate?: boolean | undefined; -}) { +export function ResourcesPage(props: ResourcesPageProps) { + const { companyId, csrfToken, relatedContent } = props; + const { + search, + setSearch, + editor, + setEditor, + setViewId, + query, + catalog, + edited, + viewed, + visible, + } = useResourcesPageController(props); const { t, i18n } = useTranslation("resources"); - const [search, setSearch] = useState(""); - const [editor, setEditor] = useState(() => initialEditor(initialCreate)); - const [viewId, setViewId] = useState(initialId); - const query = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - }); - const edited = query.data?.resources.find((item) => item.id === editor); - const viewed = query.data?.resources.find((item) => item.id === viewId); - const visible = - query.data?.resources.filter((item) => - `${item.values.name} ${item.specification.templateName}` - .toLocaleLowerCase() - .includes(search.toLocaleLowerCase()) - ) ?? []; return (
); } - -function initialEditor(create: boolean) { - return create ? "new" : undefined; -} diff --git a/modules/resources/src/web/resources/shared/controllers/index.ts b/modules/resources/src/web/resources/shared/controllers/index.ts new file mode 100644 index 0000000..bd37af9 --- /dev/null +++ b/modules/resources/src/web/resources/shared/controllers/index.ts @@ -0,0 +1,2 @@ +export { useResourceEditorController } from "./use-resource-editor-controller"; +export { useResourceFormController } from "./use-resource-form-controller"; diff --git a/modules/resources/src/web/resources/shared/controllers/use-resource-editor-controller.ts b/modules/resources/src/web/resources/shared/controllers/use-resource-editor-controller.ts new file mode 100644 index 0000000..fe9debf --- /dev/null +++ b/modules/resources/src/web/resources/shared/controllers/use-resource-editor-controller.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; + +import { catalogApi, catalogKeys } from "../../../catalog/shared/api"; +import { inventoryApi, inventoryKeys } from "../api"; +import type { ResourceEditorProps } from "../types"; + +export function useResourceEditorController({ companyId }: ResourceEditorProps) { + const catalog = useQuery({ + queryKey: catalogKeys.scope(companyId), + queryFn: () => catalogApi.get(companyId), + }); + const inventory = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => inventoryApi.get(companyId), + }); + return { catalog, inventory }; +} diff --git a/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts new file mode 100644 index 0000000..1ade3a6 --- /dev/null +++ b/modules/resources/src/web/resources/shared/controllers/use-resource-form-controller.ts @@ -0,0 +1,138 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; + +import type { ResourceInput } from "../../../../common/inventory"; +import { ResourceInputSchema } from "../../../../common/inventory"; +import { inventoryApi, inventoryKeys } from "../api"; +import { mailboxIntegration, resourceDefaults, resourceFields } from "../helpers"; +import type { ResourceFormProps } from "../types"; + +export function useResourceFormController({ + companyId, + csrfToken, + resource, + items, + onSaved, + initialKind, + initialAddress = "", + initialIntegration, +}: ResourceFormProps) { + const { t } = useTranslation("resources"); + const client = useQueryClient(); + const [creatingTemplate, setCreatingTemplate] = useState(false); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const referenceQuery = useQuery({ + queryKey: inventoryKeys.company(companyId), + queryFn: () => inventoryApi.get(companyId), + }); + const form = useForm({ + resolver: zodResolver(ResourceInputSchema), + defaultValues: resourceDefaults( + items, + resource, + initialKind, + initialAddress, + initialIntegration + ), + }); + const values = form.watch(); + const templates = items.filter( + (item) => + item.values.kind === "template" && + item.effectiveActive && + ((initialKind !== "application" && initialKind !== "certificate") || + item.builtinKey === `template:${initialKind}` || + item.values.usage === initialKind) + ); + const template = templates.find((item) => item.id === values.templateId); + const fields = resourceFields(resource, template, items); + const isSoftwareLicense = items.some( + (item) => item.id === values.templateId && item.builtinKey === "template:software_license" + ); + const helpPrefix = isSoftwareLicense ? "license" : "resource"; + const mutation = useMutation({ + mutationFn: (input: ResourceInput) => + inventoryApi.save(companyId, input, csrfToken, resource?.id), + onSuccess: async (response) => { + await Promise.all( + [ + inventoryKeys.company(companyId), + ["mailboxes"], + ["mail-configurations"], + ["access"], + ["folder-mappings"], + ["application-configurations"], + ["certificate-configurations"], + ["remote-connections", companyId], + ].map((queryKey) => client.invalidateQueries({ queryKey })) + ); + if (mounted.current) { + onSaved(response.resource.id); + } + }, + }); + const setCharacteristic = ( + fieldId: string, + value: ResourceInput["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) => { + if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") { + form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") }); + return; + } + const integration = + template?.builtinKey === "template:mailbox" + ? mailboxIntegration(items, input.integration) + : input.integration; + mutation.mutate(integration ? { ...input, integration } : input); + }); + const selectTemplate = (templateId: string) => { + form.setValue("templateId", templateId); + form.setValue("characteristics", {}); + form.setValue("integration", undefined); + }; + const changeStatus = (value: string) => { + if (value === "active" || value === "inactive") { + form.setValue("active", value === "active"); + } + }; + const templateCreated = (id: string) => { + form.setValue("templateId", id); + form.setValue("characteristics", {}); + setCreatingTemplate(false); + }; + return { + creatingTemplate, + setCreatingTemplate, + referenceQuery, + form, + values, + templates, + template, + fields, + helpPrefix, + mutation, + setCharacteristic, + submit, + selectTemplate, + changeStatus, + templateCreated, + }; +} diff --git a/modules/resources/src/web/resources/shared/helpers/index.ts b/modules/resources/src/web/resources/shared/helpers/index.ts new file mode 100644 index 0000000..8b82028 --- /dev/null +++ b/modules/resources/src/web/resources/shared/helpers/index.ts @@ -0,0 +1,2 @@ +export { requireMailboxAddress, resourceDefaults, resourceFields } from "./resource-form-values"; +export { mailboxIntegration } from "./resource-integration-values"; diff --git a/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts new file mode 100644 index 0000000..5f3b391 --- /dev/null +++ b/modules/resources/src/web/resources/shared/helpers/resource-form-values.ts @@ -0,0 +1,70 @@ +import type { CatalogItemDTO } from "../../../../common"; +import type { ResourceDTO, ResourceInput } from "../../../../common/inventory"; + +import { mailboxIntegration } from "./resource-integration-values"; + +export function resourceFields( + resource: ResourceDTO | undefined, + template: CatalogItemDTO | undefined, + items: readonly CatalogItemDTO[] +) { + return ( + resource?.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, + }, + ] + : []; + }) + : []) + ); +} + +export function requireMailboxAddress( + field: ReturnType[number], + items: readonly CatalogItemDTO[], + values: ResourceInput, + template: CatalogItemDTO | undefined +) { + return { + ...field, + required: + field.required || + (field.fieldId === mailboxIntegration(items, values.integration)?.addressFieldId && + template?.builtinKey === "template:mailbox"), + }; +} + +export function resourceDefaults( + items: readonly CatalogItemDTO[], + resource: ResourceDTO | undefined, + initialKind: "mailbox" | "access" | "application" | "certificate" | undefined, + initialAddress: string, + initialIntegration: ResourceInput["integration"] +): ResourceInput { + if (resource) { + return resource.values; + } + const template = initialKind + ? items.find((item) => item.builtinKey === `template:${initialKind}`) + : undefined; + const addressField = items.find((item) => item.builtinKey === "field:email"); + return { + name: initialAddress.slice(0, 100), + templateId: template?.id ?? "", + active: true, + integration: initialIntegration, + notes: "", + characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {}, + }; +} diff --git a/modules/resources/src/web/resources/shared/helpers/resource-integration-values.ts b/modules/resources/src/web/resources/shared/helpers/resource-integration-values.ts new file mode 100644 index 0000000..c1787ff --- /dev/null +++ b/modules/resources/src/web/resources/shared/helpers/resource-integration-values.ts @@ -0,0 +1,21 @@ +import type { CatalogItemDTO, ResourceInput } from "../../../../common"; + +export function mailboxIntegration( + items: readonly CatalogItemDTO[], + previous?: ResourceInput["integration"] +) { + if (previous?.kind === "mailbox") { + return previous; + } + const addressFieldId = items.find((item) => item.builtinKey === "field:email")?.id; + const contextFieldId = items.find((item) => item.builtinKey === "field:context")?.id; + return addressFieldId && contextFieldId + ? { + kind: "mailbox" as const, + addressFieldId, + contextFieldId, + mailboxKind: "corporate" as const, + accountId: null, + } + : undefined; +} diff --git a/modules/resources/src/web/resources/shared/resource-values.test.ts b/modules/resources/src/web/resources/shared/resource-values.test.ts new file mode 100644 index 0000000..9a32608 --- /dev/null +++ b/modules/resources/src/web/resources/shared/resource-values.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { mailboxIntegration, resourceDefaults, resourceFields } from "./helpers"; + +const FIELD_ID = "e0420eb9-2aab-4368-96a0-2ce83dd23eb4"; + +test("la integración de buzón conserva su configuración explícita y no inventa referencias", () => { + const previous = { + kind: "mailbox", + addressFieldId: FIELD_ID, + contextFieldId: FIELD_ID, + mailboxKind: "external", + accountId: null, + } as const; + assert.equal(mailboxIntegration([], previous), previous); + assert.equal(mailboxIntegration([]), undefined); +}); + +test("los valores iniciales toleran catálogo pendiente y limitan el nombre sin cambiar la entrada", () => { + const name = "a".repeat(120); + const values = resourceDefaults([], undefined, "mailbox", name, undefined); + assert.equal(values.name.length, 100); + assert.equal(values.templateId, ""); + assert.equal(values.integration, undefined); + assert.deepEqual(values.characteristics, {}); + assert.deepEqual(resourceFields(undefined, undefined, []), []); + assert.equal(name.length, 120); +}); diff --git a/modules/resources/src/web/resources/shared/resources.test.tsx b/modules/resources/src/web/resources/shared/resources.test.tsx index e526886..ace0a1d 100644 --- a/modules/resources/src/web/resources/shared/resources.test.tsx +++ b/modules/resources/src/web/resources/shared/resources.test.tsx @@ -12,7 +12,7 @@ import { I18nextProvider } from "react-i18next"; import { buildCatalogResponse } from "../../../api/application/resource-catalog-response.builder"; import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate"; import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed"; -import { catalogKeys } from "../../catalog"; +import { catalogKeys } from "../../catalog/shared/api"; import { resourcesEnglish, resourcesSpanish } from "../../shared"; import { ResourcesPage } from "../list"; diff --git a/modules/resources/src/web/resources/shared/types/index.ts b/modules/resources/src/web/resources/shared/types/index.ts new file mode 100644 index 0000000..4cdd0d1 --- /dev/null +++ b/modules/resources/src/web/resources/shared/types/index.ts @@ -0,0 +1,2 @@ +export type { ResourceEditorProps } from "./resource-editor-props"; +export type { ResourceFormProps } from "./resource-form-props"; diff --git a/modules/resources/src/web/resources/shared/types/resource-editor-props.ts b/modules/resources/src/web/resources/shared/types/resource-editor-props.ts new file mode 100644 index 0000000..046b421 --- /dev/null +++ b/modules/resources/src/web/resources/shared/types/resource-editor-props.ts @@ -0,0 +1,16 @@ +export type ResourceEditorProps = { + companyId: string; + csrfToken: string; + resourceId?: string | undefined; + initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined; + initialAddress?: string; + initialAccess?: + | { + resourceTypeId: string; + systemId: string | null; + environmentId: string | null; + } + | undefined; + onSaved: (id: string) => void; + allowQuickCreate?: boolean; +}; diff --git a/modules/resources/src/web/resources/shared/types/resource-form-props.ts b/modules/resources/src/web/resources/shared/types/resource-form-props.ts new file mode 100644 index 0000000..78c8bf9 --- /dev/null +++ b/modules/resources/src/web/resources/shared/types/resource-form-props.ts @@ -0,0 +1,14 @@ +import type { CatalogItemDTO } from "../../../../common"; +import type { ResourceDTO, ResourceInput } from "../../../../common/inventory"; + +export type ResourceFormProps = { + companyId: string; + csrfToken: string; + resource?: ResourceDTO | undefined; + items: readonly CatalogItemDTO[]; + onSaved: (id: string) => void; + allowQuickCreate?: boolean; + initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined; + initialAddress?: string; + initialIntegration?: ResourceInput["integration"]; +}; diff --git a/modules/resources/src/web/resources/shared/ui/resource-editor.tsx b/modules/resources/src/web/resources/shared/ui/resource-editor.tsx index b8b0fc0..d9a6d5d 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-editor.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-editor.tsx @@ -1,41 +1,24 @@ -import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { catalogApi, catalogKeys } from "../../../catalog"; import { InventoryFeedback } from "../../../shared"; -import { inventoryApi, inventoryKeys } from "../api"; +import { useResourceEditorController } from "../controllers"; +import type { ResourceEditorProps } from "../types"; import { ResourceForm } from "./resource-form"; -export function ResourceEditor({ - companyId, - csrfToken, - resourceId, - initialKind, - initialAddress = "", - initialAccess, - onSaved, - allowQuickCreate = true, -}: { - companyId: string; - csrfToken: string; - resourceId?: string | undefined; - initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined; - initialAddress?: string; - initialAccess?: - | { resourceTypeId: string; systemId: string | null; environmentId: string | null } - | undefined; - onSaved: (id: string) => void; - allowQuickCreate?: boolean; -}) { + +export function ResourceEditor(props: ResourceEditorProps) { + const { + companyId, + csrfToken, + resourceId, + initialKind, + initialAddress = "", + initialAccess, + onSaved, + allowQuickCreate = true, + } = props; + const { catalog, inventory } = useResourceEditorController(props); const { t } = useTranslation("resources"); - const catalog = useQuery({ - queryKey: catalogKeys.scope(companyId), - queryFn: () => catalogApi.get(companyId), - }); - const inventory = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); if (catalog.isPending || inventory.isPending) { return

{t("loading")}

; } diff --git a/modules/resources/src/web/resources/shared/ui/resource-form.tsx b/modules/resources/src/web/resources/shared/ui/resource-form.tsx index f6278fa..f22844c 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-form.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-form.tsx @@ -1,4 +1,3 @@ -import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@repo/shadcn-ui/components/button"; import { ContextHelp } from "@repo/shadcn-ui/components/context-help"; import { FormActions } from "@repo/shadcn-ui/components/form-dialog"; @@ -6,131 +5,42 @@ import { Input } from "@repo/shadcn-ui/components/input"; import { Label } from "@repo/shadcn-ui/components/label"; import { SelectWithAction } from "@repo/shadcn-ui/components/select-with-action"; import { Textarea } from "@repo/shadcn-ui/components/textarea"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useId, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; +import { useId } from "react"; import { useTranslation } from "react-i18next"; -import type { CatalogItemDTO } from "../../../../common"; -import type { ResourceDTO, ResourceInput } from "../../../../common/inventory"; -import { ResourceInputSchema } from "../../../../common/inventory"; import { CatalogTemplateCreateDialog } from "../../../catalog-templates"; import { InventoryFeedback } from "../../../shared"; -import { inventoryApi, inventoryKeys } from "../api"; +import { useResourceFormController } from "../controllers"; +import { requireMailboxAddress } from "../helpers"; +import type { ResourceFormProps } from "../types"; import { ResourceCharacteristicControl } from "./resource-characteristic-control"; -import { ResourceIntegrationFields, mailboxIntegration } from "./resource-integration-fields"; +import { ResourceIntegrationFields } from "./resource-integration-fields"; -export function ResourceForm({ - companyId, - csrfToken, - resource, - items, - onSaved, - allowQuickCreate = true, - initialKind, - initialAddress = "", - initialIntegration, -}: { - companyId: string; - csrfToken: string; - resource?: ResourceDTO | undefined; - items: readonly CatalogItemDTO[]; - onSaved: (id: string) => void; - allowQuickCreate?: boolean; - initialKind?: "mailbox" | "access" | "application" | "certificate" | undefined; - initialAddress?: string; - initialIntegration?: ResourceInput["integration"]; -}) { +export function ResourceForm(props: ResourceFormProps) { + const { companyId, csrfToken, resource, items, allowQuickCreate = true } = props; + const { + creatingTemplate, + setCreatingTemplate, + referenceQuery, + form, + values, + templates, + template, + fields, + helpPrefix, + mutation, + setCharacteristic, + submit, + selectTemplate, + changeStatus, + templateCreated, + } = useResourceFormController(props); const { t } = useTranslation("resources"); const prefix = useId(); - const client = useQueryClient(); - const [creatingTemplate, setCreatingTemplate] = useState(false); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - }; - }, []); - const referenceQuery = useQuery({ - queryKey: inventoryKeys.company(companyId), - queryFn: () => inventoryApi.get(companyId), - }); - const form = useForm({ - resolver: zodResolver(ResourceInputSchema), - defaultValues: resourceDefaults( - items, - resource, - initialKind, - initialAddress, - initialIntegration - ), - }); - const values = form.watch(); - const templates = items.filter( - (item) => - item.values.kind === "template" && - item.effectiveActive && - ((initialKind !== "application" && initialKind !== "certificate") || - item.builtinKey === `template:${initialKind}` || - item.values.usage === initialKind) - ); - const template = templates.find((item) => item.id === values.templateId); - const fields = resourceFields(resource, template, items); - const isSoftwareLicense = items.some( - (item) => item.id === values.templateId && item.builtinKey === "template:software_license" - ); - const helpPrefix = isSoftwareLicense ? "license" : "resource"; - const mutation = useMutation({ - mutationFn: (input: ResourceInput) => - inventoryApi.save(companyId, input, csrfToken, resource?.id), - onSuccess: async (response) => { - await Promise.all( - [ - inventoryKeys.company(companyId), - ["mailboxes"], - ["mail-configurations"], - ["access"], - ["folder-mappings"], - ["application-configurations"], - ["certificate-configurations"], - ["remote-connections", companyId], - ].map((queryKey) => client.invalidateQueries({ queryKey })) - ); - if (mounted.current) { - onSaved(response.resource.id); - } - }, - }); - const setCharacteristic = ( - fieldId: string, - value: ResourceInput["characteristics"][string] | undefined - ) => { - const characteristics = { ...form.getValues("characteristics") }; - if (value === undefined) { - delete characteristics[fieldId]; - } else { - characteristics[fieldId] = value; - } - form.setValue("characteristics", characteristics, { shouldDirty: true }); - }; return ( <> - { - if (template?.builtinKey === "template:access" && input.integration?.kind !== "access") { - form.setError("integration", { message: t("inventoryErrors.REFERENCE_UNAVAILABLE") }); - return; - } - const integration = - template?.builtinKey === "template:mailbox" - ? mailboxIntegration(items, input.integration) - : input.integration; - mutation.mutate(integration ? { ...input, integration } : input); - })} - > +
@@ -148,11 +58,7 @@ export function ResourceForm({ disabled={!!resource || mutation.isPending} id={`${prefix}-template`} label={t("template")} - onValueChange={(templateId) => { - form.setValue("templateId", templateId); - form.setValue("characteristics", {}); - form.setValue("integration", undefined); - }} + onValueChange={selectTemplate} options={ resource ? [ @@ -174,11 +80,7 @@ export function ResourceForm({ disabled={mutation.isPending} id={`${prefix}-status`} label={t("status")} - onValueChange={(value) => { - if (value === "active" || value === "inactive") { - form.setValue("active", value === "active"); - } - }} + onValueChange={changeStatus} options={["active", "inactive"].map((value) => ({ value, label: t(value) }))} placeholder={t("chooseValue")} required @@ -254,80 +156,10 @@ export function ResourceForm({ setCreatingTemplate(false); } }} - onSaved={(id) => { - form.setValue("templateId", id); - form.setValue("characteristics", {}); - setCreatingTemplate(false); - }} + onSaved={templateCreated} scope={companyId} /> )} ); } - -function resourceFields( - resource: ResourceDTO | undefined, - template: CatalogItemDTO | undefined, - items: readonly CatalogItemDTO[] -) { - return ( - resource?.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, - }, - ] - : []; - }) - : []) - ); -} - -function requireMailboxAddress( - field: ReturnType[number], - items: readonly CatalogItemDTO[], - values: ResourceInput, - template: CatalogItemDTO | undefined -) { - return { - ...field, - required: - field.required || - (field.fieldId === mailboxIntegration(items, values.integration)?.addressFieldId && - template?.builtinKey === "template:mailbox"), - }; -} - -function resourceDefaults( - items: readonly CatalogItemDTO[], - resource: ResourceDTO | undefined, - initialKind: "mailbox" | "access" | "application" | "certificate" | undefined, - initialAddress: string, - initialIntegration: ResourceInput["integration"] -): ResourceInput { - if (resource) { - return resource.values; - } - const template = initialKind - ? items.find((item) => item.builtinKey === `template:${initialKind}`) - : undefined; - const addressField = items.find((item) => item.builtinKey === "field:email"); - return { - name: initialAddress.slice(0, 100), - templateId: template?.id ?? "", - active: true, - integration: initialIntegration, - notes: "", - characteristics: addressField && initialAddress ? { [addressField.id]: initialAddress } : {}, - }; -} diff --git a/modules/resources/src/web/resources/shared/ui/resource-integration-fields.tsx b/modules/resources/src/web/resources/shared/ui/resource-integration-fields.tsx index f28028d..1a395a4 100644 --- a/modules/resources/src/web/resources/shared/ui/resource-integration-fields.tsx +++ b/modules/resources/src/web/resources/shared/ui/resource-integration-fields.tsx @@ -4,26 +4,8 @@ import { useId } from "react"; import { useTranslation } from "react-i18next"; import type { CatalogItemDTO, ResourceInput, ResourceReferenceDTO } from "../../../../common"; +import { mailboxIntegration } from "../helpers"; -export function mailboxIntegration( - items: readonly CatalogItemDTO[], - previous?: ResourceInput["integration"] -) { - if (previous?.kind === "mailbox") { - return previous; - } - const addressFieldId = items.find((item) => item.builtinKey === "field:email")?.id; - const contextFieldId = items.find((item) => item.builtinKey === "field:context")?.id; - return addressFieldId && contextFieldId - ? { - kind: "mailbox" as const, - addressFieldId, - contextFieldId, - mailboxKind: "corporate" as const, - accountId: null, - } - : undefined; -} export function ResourceIntegrationFields({ templateKey, integration,