From a2976ed7c80a65d5bef02f39b45b2e93be743bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?BITARD=20Micha=C3=ABl?= <michael.bitard@beta.gouv.fr> Date: Tue, 22 Apr 2025 09:24:06 +0000 Subject: [PATCH] =?UTF-8?q?feat(action):=20les=20utilisateurs=20super=20pe?= =?UTF-8?q?uvent=20voir=20les=20modifications=20effectu=C3=A9es=20sur=20Ca?= =?UTF-8?q?mino=20(pub/pnm-public/camino!1696)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/api/rest/logs.queries.ts | 17 +- .../api/src/api/rest/logs.queries.types.ts | 2 + .../api/src/api/rest/mutations.queries.ts | 73 +++++ .../src/api/rest/mutations.queries.types.ts | 52 ++++ .../api/rest/mutations.test.integration.ts | 87 ++++++ packages/api/src/api/rest/mutations.ts | 59 ++++ .../migrations/20250408141930_better-logs.ts | 71 +++++ packages/api/src/server/rest.ts | 22 +- packages/common/src/date.test.ts | 4 + packages/common/src/date.ts | 2 + packages/common/src/filters.ts | 1 + packages/common/src/mutations.ts | 26 ++ .../common/src/permissions/mutation.test.ts | 7 + packages/common/src/permissions/mutation.ts | 3 + packages/common/src/rest.ts | 5 + packages/ui/src/components/_common/liste.tsx | 9 +- .../journaux/journaux-api-client.ts | 7 + .../ui/src/components/mutation.stories.tsx | 36 +++ .../mutation.stories_snapshots_Default.html | 9 + .../mutation.stories_snapshots_Loading.html | 5 + .../mutation.stories_snapshots_WithError.html | 7 + packages/ui/src/components/mutation.tsx | 63 +++++ .../ui/src/components/mutations.stories.tsx | 66 +++++ .../mutations.stories_snapshots_Default.html | 73 +++++ .../mutations.stories_snapshots_Loading.html | 125 +++++++++ ...mutations.stories_snapshots_WithError.html | 127 +++++++++ packages/ui/src/components/mutations.tsx | 261 ++++++++++++++++++ ...der.stories_snapshots_CanOpenAnnuaire.html | 1 + .../page/header.stories_snapshots_Super.html | 1 + packages/ui/src/components/page/menu.ts | 3 +- .../page/plan.stories_snapshots_Super.html | 1 + packages/ui/src/router/index.ts | 23 +- packages/ui/src/router/routes.ts | 23 +- 33 files changed, 1255 insertions(+), 16 deletions(-) create mode 100644 packages/api/src/api/rest/mutations.queries.ts create mode 100644 packages/api/src/api/rest/mutations.queries.types.ts create mode 100644 packages/api/src/api/rest/mutations.test.integration.ts create mode 100644 packages/api/src/api/rest/mutations.ts create mode 100644 packages/api/src/knex/migrations/20250408141930_better-logs.ts create mode 100644 packages/common/src/mutations.ts create mode 100644 packages/common/src/permissions/mutation.test.ts create mode 100644 packages/common/src/permissions/mutation.ts create mode 100644 packages/ui/src/components/mutation.stories.tsx create mode 100644 packages/ui/src/components/mutation.stories_snapshots_Default.html create mode 100644 packages/ui/src/components/mutation.stories_snapshots_Loading.html create mode 100644 packages/ui/src/components/mutation.stories_snapshots_WithError.html create mode 100644 packages/ui/src/components/mutation.tsx create mode 100644 packages/ui/src/components/mutations.stories.tsx create mode 100644 packages/ui/src/components/mutations.stories_snapshots_Default.html create mode 100644 packages/ui/src/components/mutations.stories_snapshots_Loading.html create mode 100644 packages/ui/src/components/mutations.stories_snapshots_WithError.html create mode 100644 packages/ui/src/components/mutations.tsx diff --git a/packages/api/src/api/rest/logs.queries.ts b/packages/api/src/api/rest/logs.queries.ts index 44c44e40d..de942e3dc 100644 --- a/packages/api/src/api/rest/logs.queries.ts +++ b/packages/api/src/api/rest/logs.queries.ts @@ -6,18 +6,27 @@ import { IInsertLogInternalQuery } from './logs.queries.types' import { UtilisateurId } from 'camino-common/src/roles' import { CaminoError } from 'camino-common/src/zod-tools' import { Effect } from 'effect' +import { LogMethod } from 'camino-common/src/mutations' +import { CaminoRequest } from './express-type' type Log = { utilisateur_id: UtilisateurId method: string path: string body: any + camino_path: string + camino_variables: Record<string, string> } -export const addLog = (pool: Pool, utilisateur_id: UtilisateurId, method: string, path: string, body: unknown): Effect.Effect<void[], CaminoError<EffectDbQueryAndValidateErrors>> => - effectDbQueryAndValidate(insertLogInternal, { utilisateur_id, method, path, body: JSON.stringify(body) }, pool, z.void()) +export const addLog = ( + pool: Pool, + utilisateur_id: UtilisateurId, + method: LogMethod, + req: Pick<CaminoRequest, 'url' | 'route' | 'body' | 'params'> +): Effect.Effect<void[], CaminoError<EffectDbQueryAndValidateErrors>> => + effectDbQueryAndValidate(insertLogInternal, { utilisateur_id, method, path: req.url, body: JSON.stringify(req.body), camino_path: req.route.path, camino_variables: req.params }, pool, z.void()) const insertLogInternal = sql<Redefine<IInsertLogInternalQuery, Log, void>>` -insert into logs (utilisateur_id, path, method, body) - values ($ utilisateur_id !, $path !, $ method !, $ body) +insert into logs (utilisateur_id, path, method, body, camino_path, camino_variables) + values ($ utilisateur_id !, $path !, $ method !, $ body, $camino_path!, $camino_variables) ; ` diff --git a/packages/api/src/api/rest/logs.queries.types.ts b/packages/api/src/api/rest/logs.queries.types.ts index 9f70bcdef..b774b3ffd 100644 --- a/packages/api/src/api/rest/logs.queries.types.ts +++ b/packages/api/src/api/rest/logs.queries.types.ts @@ -4,6 +4,8 @@ export type Json = null | boolean | number | string | Json[] | { [key: string]: /** 'InsertLogInternal' parameters type */ export interface IInsertLogInternalParams { body?: Json | null | void; + camino_path: string; + camino_variables?: Json | null | void; method: string; path: string; utilisateur_id: string; diff --git a/packages/api/src/api/rest/mutations.queries.ts b/packages/api/src/api/rest/mutations.queries.ts new file mode 100644 index 000000000..90149f595 --- /dev/null +++ b/packages/api/src/api/rest/mutations.queries.ts @@ -0,0 +1,73 @@ +import { sql } from '@pgtyped/runtime' +import { DBNotFound, EffectDbQueryAndValidateErrors, Redefine, dbNotFoundError, effectDbQueryAndValidate } from '../../pg-database.js' +import { Pool } from 'pg' +import { Effect } from 'effect' +import { CaminoError } from 'camino-common/src/zod-tools.js' +import { logsMethodValidator, MutationId, mutationIdValidator } from 'camino-common/src/mutations.js' +import { IGetMutationDbQuery, IGetMutationsDbQuery } from './mutations.queries.types.js' +import { z } from 'zod' +import { utilisateurIdValidator } from 'camino-common/src/roles.js' + +const mutationDbValidator = z.object({ + id: mutationIdValidator, + datetime: z.date(), + method: logsMethodValidator, + path: z.string(), + utilisateur_nom: z.string(), + utilisateur_prenom: z.string(), + utilisateur_id: utilisateurIdValidator, + camino_path: z.string(), + camino_variables: z.record(z.string(), z.string()), +}) + +type MutationDb = z.infer<typeof mutationDbValidator> + +export const getMutations = (pool: Pool, email: string | undefined): Effect.Effect<MutationDb[], CaminoError<EffectDbQueryAndValidateErrors>> => + effectDbQueryAndValidate(getMutationsDb, { email: `%${email ?? ''}%` }, pool, mutationDbValidator) + +const getMutationsDb = sql<Redefine<IGetMutationsDbQuery, { email: string | undefined }, MutationDb>>` +SELECT + l.id, + datetime, + path, + method, + u.nom as utilisateur_nom, + u.prenom as utilisateur_prenom, + u.id as utilisateur_id, + camino_path, + camino_variables +FROM logs l +JOIN utilisateurs u ON u.id = l.utilisateur_id +WHERE ($email::TEXT = '%%' OR u.email LIKE $email) +ORDER BY datetime desc +` + +export type GetMutationErrors = EffectDbQueryAndValidateErrors | DBNotFound + +const getMutationDbValidator = mutationDbValidator.extend({ body: z.object({}).passthrough() }) +type GetMutationDb = z.infer<typeof getMutationDbValidator> +export const getMutation = (pool: Pool, mutationId: MutationId): Effect.Effect<GetMutationDb, CaminoError<GetMutationErrors>> => + effectDbQueryAndValidate(getMutationDb, { mutationId }, pool, getMutationDbValidator).pipe( + Effect.filterOrFail( + result => result.length === 1, + () => ({ message: dbNotFoundError }) + ), + Effect.map(result => result[0]) + ) + +const getMutationDb = sql<Redefine<IGetMutationDbQuery, { mutationId: MutationId }, GetMutationDb>>` +SELECT + l.id, + datetime, + path, + method, + body, + u.nom as utilisateur_nom, + u.prenom as utilisateur_prenom, + u.id as utilisateur_id, + camino_path, + camino_variables +FROM logs l +JOIN utilisateurs u ON u.id = l.utilisateur_id +WHERE l.id = $mutationId! +` diff --git a/packages/api/src/api/rest/mutations.queries.types.ts b/packages/api/src/api/rest/mutations.queries.types.ts new file mode 100644 index 000000000..c2b7bd734 --- /dev/null +++ b/packages/api/src/api/rest/mutations.queries.types.ts @@ -0,0 +1,52 @@ +/** Types generated for queries found in "src/api/rest/mutations.queries.ts" */ +export type Json = null | boolean | number | string | Json[] | { [key: string]: Json }; + +/** 'GetMutationsDb' parameters type */ +export interface IGetMutationsDbParams { + email?: string | null | void; +} + +/** 'GetMutationsDb' return type */ +export interface IGetMutationsDbResult { + camino_path: string; + camino_variables: Json; + datetime: Date; + id: string; + method: string; + path: string | null; + utilisateur_id: string; + utilisateur_nom: string | null; + utilisateur_prenom: string | null; +} + +/** 'GetMutationsDb' query type */ +export interface IGetMutationsDbQuery { + params: IGetMutationsDbParams; + result: IGetMutationsDbResult; +} + +/** 'GetMutationDb' parameters type */ +export interface IGetMutationDbParams { + mutationId: string; +} + +/** 'GetMutationDb' return type */ +export interface IGetMutationDbResult { + body: Json | null; + camino_path: string; + camino_variables: Json; + datetime: Date; + id: string; + method: string; + path: string | null; + utilisateur_id: string; + utilisateur_nom: string | null; + utilisateur_prenom: string | null; +} + +/** 'GetMutationDb' query type */ +export interface IGetMutationDbQuery { + params: IGetMutationDbParams; + result: IGetMutationDbResult; +} + diff --git a/packages/api/src/api/rest/mutations.test.integration.ts b/packages/api/src/api/rest/mutations.test.integration.ts new file mode 100644 index 000000000..e8be1c0f0 --- /dev/null +++ b/packages/api/src/api/rest/mutations.test.integration.ts @@ -0,0 +1,87 @@ +import { dbManager } from '../../../tests/db-manager' +import { restNewCall } from '../../../tests/_utils/index' +import { afterAll, beforeAll, describe, test, expect, vi, beforeEach } from 'vitest' +import type { Pool } from 'pg' +import { ADMINISTRATION_IDS } from 'camino-common/src/static/administrations' +import { mutationIdValidator } from 'camino-common/src/mutations' +import { createUtilisateur } from '../../database/queries/utilisateurs.queries' +import { getCurrent } from 'camino-common/src/date' +import { newUtilisateurId } from '../../database/models/_format/id-create' +import { callAndExit } from '../../tools/fp-tools' +import { addLog } from './logs.queries' +import { HTTP_STATUS } from 'camino-common/src/http' + +console.info = vi.fn() +console.warn = vi.fn() +console.error = vi.fn() + +beforeEach(() => { + vi.resetAllMocks() +}) + +let dbPool: Pool + +beforeAll(async () => { + const { pool } = await dbManager.populateDb() + dbPool = pool + + const userId = newUtilisateurId('userIdForMutation') + await createUtilisateur(dbPool, { + role: 'defaut', + id: userId, + email: '', + nom: '', + prenom: '', + telephone_fixe: null, + telephone_mobile: null, + date_creation: getCurrent(), + keycloak_id: '', + }) + await callAndExit( + addLog(dbPool, userId, 'post', { + params: {}, + url: '/rest/anyurl', + body: {}, + route: { path: 'path' }, + }) + ) +}) + +afterAll(async () => { + await dbManager.closeKnex() +}) + +describe('getMutations', () => { + test('ne peut pas récupérer les mutations si non super', async () => { + const tested = await restNewCall(dbPool, '/rest/mutations', {}, { role: 'admin', administrationId: ADMINISTRATION_IDS['DGALN/DEB/EARM2'] }) + + expect(tested.body).toMatchInlineSnapshot(` + { + "message": "Droits insuffisants", + "status": 403, + } + `) + }) + test('récupère les mutations, puis une seule', async () => { + let tested = await restNewCall(dbPool, '/rest/mutations', {}, { role: 'super' }) + + expect(tested.body, JSON.stringify(tested.body)).toHaveLength(1) + const mutationId = tested.body[0].id + tested = await restNewCall(dbPool, '/rest/mutations/:mutationId', { mutationId: mutationId }, { role: 'super' }) + expect(tested.statusCode, JSON.stringify(tested.body)).toBe(HTTP_STATUS.OK) + }) +}) + +describe('getMutation', () => { + test('ne peut pas récupérer une mutation si non super', async () => { + const mutationId = mutationIdValidator.parse('3b1d61bc-1f62-445c-a18f-22ca95b82fc3') + const tested = await restNewCall(dbPool, '/rest/mutations/:mutationId', { mutationId: mutationId }, { role: 'admin', administrationId: ADMINISTRATION_IDS['DGALN/DEB/EARM2'] }) + + expect(tested.body).toMatchInlineSnapshot(` + { + "message": "Droits insuffisants", + "status": 403, + } + `) + }) +}) diff --git a/packages/api/src/api/rest/mutations.ts b/packages/api/src/api/rest/mutations.ts new file mode 100644 index 000000000..301518814 --- /dev/null +++ b/packages/api/src/api/rest/mutations.ts @@ -0,0 +1,59 @@ +import { HTTP_STATUS } from 'camino-common/src/http' +import { RestNewGetCall } from '../../server/rest' +import { CaminoApiError } from '../../types' +import { EffectDbQueryAndValidateErrors } from '../../pg-database' +import { Effect, Match } from 'effect' +import { GetMutation, Mutation } from 'camino-common/src/mutations' +import { canReadMutation } from 'camino-common/src/permissions/mutation' +import { getMutation, GetMutationErrors, getMutations } from './mutations.queries' + +export const getRestMutations: RestNewGetCall<'/rest/mutations'> = (rootPipe): Effect.Effect<Mutation[], CaminoApiError<EffectDbQueryAndValidateErrors | typeof droitsInsuffisants>> => + rootPipe.pipe( + Effect.filterOrFail( + ({ user }) => canReadMutation(user), + () => ({ message: droitsInsuffisants }) + ), + Effect.flatMap(({ pool, searchParams }) => getMutations(pool, searchParams.emails)), + Effect.map(result => result.map(value => ({ ...value, datetime: value.datetime.toISOString() }))), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.whenOr('Droits insuffisants', () => ({ + ...caminoError, + status: HTTP_STATUS.FORBIDDEN, + })), + Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({ + ...caminoError, + status: HTTP_STATUS.INTERNAL_SERVER_ERROR, + })), + Match.exhaustive + ) + ) + ) + +const droitsInsuffisants = 'Droits insuffisants' as const +export const getRestMutation: RestNewGetCall<'/rest/mutations/:mutationId'> = (rootPipe): Effect.Effect<GetMutation, CaminoApiError<GetMutationErrors | typeof droitsInsuffisants>> => + rootPipe.pipe( + Effect.filterOrFail( + ({ user }) => canReadMutation(user), + () => ({ message: droitsInsuffisants }) + ), + Effect.flatMap(({ pool, params }) => getMutation(pool, params.mutationId)), + Effect.map(value => ({ ...value, datetime: value.datetime.toISOString(), body: JSON.stringify(value.body) })), + Effect.mapError(caminoError => + Match.value(caminoError.message).pipe( + Match.whenOr("Impossible d'exécuter la requête dans la base de données", 'Les données en base ne correspondent pas à ce qui est attendu', () => ({ + ...caminoError, + status: HTTP_STATUS.INTERNAL_SERVER_ERROR, + })), + Match.whenOr('Droits insuffisants', () => ({ + ...caminoError, + status: HTTP_STATUS.FORBIDDEN, + })), + Match.whenOr('Élément non trouvé dans la base de données', () => ({ + ...caminoError, + status: HTTP_STATUS.NOT_FOUND, + })), + Match.exhaustive + ) + ) + ) diff --git a/packages/api/src/knex/migrations/20250408141930_better-logs.ts b/packages/api/src/knex/migrations/20250408141930_better-logs.ts new file mode 100644 index 000000000..24509c871 --- /dev/null +++ b/packages/api/src/knex/migrations/20250408141930_better-logs.ts @@ -0,0 +1,71 @@ +import { NewPostRestRoutes, NewPutRestRoutes, DeleteRestRoutes, NewDeleteRestRoutes } from 'camino-common/src/rest' +import { Knex } from 'knex' + +// On peut trouver toutes les routes qui mutent avec PostRestRoutes | NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes +export const up = async (knex: Knex): Promise<void> => { + await knex.raw('alter table logs add column id uuid DEFAULT gen_random_uuid() PRIMARY KEY') + await knex.raw('alter table logs add column camino_path varchar') + await knex.raw('alter table logs add column camino_variables jsonb') + + const logs: { rows: { id: string; datetime: Date; path: string; method: string; body: string; utilisateur_id: string }[] } = await knex.raw('select * from logs') + + for (const log of logs.rows) { + let camino_path: NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes + let camino_variables: Record<string, string> = {} + if (log.path.endsWith('/permission')) { + camino_path = '/rest/utilisateurs/:id/permission' + camino_variables = { id: log.path.split('/')[3] } + } else if (log.path.endsWith('/abonne')) { + camino_path = '/rest/titres/:titreId/abonne' + camino_variables = { titreId: log.path.split('/')[3] } + } else if (log.path.endsWith('/titreLiaisons')) { + camino_path = '/rest/titres/:id/titreLiaisons' + camino_variables = { id: log.path.split('/')[3] } + } else if (log.path.startsWith('/rest/activites/')) { + camino_path = '/rest/activites/:activiteId' + camino_variables = { activiteId: log.path.split('/')[3] } + } else if (log.path.endsWith('/depot')) { + camino_path = '/rest/etapes/:etapeId/depot' + camino_variables = { etapeId: log.path.split('/')[3] } + } else if (log.path.endsWith('/activiteTypeEmails/delete')) { + camino_path = '/rest/administrations/:administrationId/activiteTypeEmails/delete' + camino_variables = { administrationId: log.path.split('/')[3] } + } else if (log.path.endsWith('/activiteTypeEmails')) { + camino_path = '/rest/administrations/:administrationId/activiteTypeEmails' + camino_variables = { administrationId: log.path.split('/')[3] } + } else if (log.path.endsWith('/documents')) { + camino_path = '/rest/entreprises/:entrepriseId/documents' + camino_variables = { entrepriseId: log.path.split('/')[3] } + } else if (log.path.startsWith('/rest/entreprises/') && log.path.includes('/documents/')) { + camino_path = '/rest/entreprises/:entrepriseId/documents/:entrepriseDocumentId' + camino_variables = { entrepriseId: log.path.split('/')[3], entrepriseDocumentId: log.path.split('/')[5] } + } else if (log.path.startsWith('/rest/entreprises/')) { + camino_path = '/rest/entreprises/:entrepriseId' + camino_variables = { entrepriseId: log.path.split('/')[3] } + } else if (log.path.startsWith('/rest/titres/')) { + camino_path = '/rest/titres/:titreId' + camino_variables = { titreId: log.path.split('/')[3] } + } else if (log.path.startsWith('/rest/demarches/')) { + camino_path = '/rest/demarches/:demarcheIdOrSlug' + camino_variables = { demarcheIdOrSlug: log.path.split('/')[3] } + } else if (log.path.startsWith('/rest/etapes/')) { + camino_path = '/rest/etapes/:etapeIdOrSlug' + camino_variables = { etapeIdOrSlug: log.path.split('/')[3] } + } else if (log.path.startsWith('/rest/geojson/import/')) { + camino_path = '/rest/geojson/import/:geoSystemeId' + } else if (log.path.startsWith('/rest/geojson_forages/import/')) { + camino_path = '/rest/geojson_forages/import/:geoSystemeId' + } else if (log.path.startsWith('/rest/geojson_points/import/')) { + camino_path = '/rest/geojson_points/import/:geoSystemeId' + } else { + camino_path = log.path as NewPostRestRoutes + } + + await knex.raw('UPDATE logs SET camino_path=?, camino_variables=? WHERE id=?', [camino_path, camino_variables, log.id]) + } + + await knex.raw('ALTER TABLE logs ALTER COLUMN camino_path set not null') + await knex.raw('ALTER TABLE logs ALTER COLUMN camino_variables set not null') +} + +export const down = (): void => {} diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts index 353fd1af8..2844cba4b 100644 --- a/packages/api/src/server/rest.ts +++ b/packages/api/src/server/rest.ts @@ -54,9 +54,11 @@ import { titreDemandeCreer } from '../api/rest/titre-demande' import { config } from '../config/index' import { addLog } from '../api/rest/logs.queries' import { HTTP_STATUS } from 'camino-common/src/http' -import { zodParseEffectTyped } from '../tools/fp-tools' +import { callAndExit, zodParseEffectTyped } from '../tools/fp-tools' import { Cause, Effect, Exit, Option, pipe } from 'effect' import { quickAccessSearch } from '../api/rest/quick-access' +import { getRestMutation, getRestMutations } from '../api/rest/mutations' +import { LogMethod } from 'camino-common/src/mutations' interface IRestResolverResult { nom: string @@ -238,6 +240,8 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k '/rest/activites/:activiteId': { getCall: getActivite, newPutCall: updateActivite, deleteCall: deleteActivite, ...CaminoRestRoutes['/rest/activites/:activiteId'] }, '/rest/activitesSuper': { newGetCall: getActivitesForTDBSuper, ...CaminoRestRoutes['/rest/activitesSuper'] }, '/rest/communes': { newGetCall: getCommunes, ...CaminoRestRoutes['/rest/communes'] }, + '/rest/mutations': { newGetCall: getRestMutations, ...CaminoRestRoutes['/rest/mutations'] }, + '/rest/mutations/:mutationId': { newGetCall: getRestMutation, ...CaminoRestRoutes['/rest/mutations/:mutationId'] }, '/rest/geojson/import/:geoSystemeId': { newPostCall: geojsonImport, ...CaminoRestRoutes['/rest/geojson/import/:geoSystemeId'] }, '/rest/geojson_points/import/:geoSystemeId': { newPostCall: geojsonImportPoints, ...CaminoRestRoutes['/rest/geojson_points/import/:geoSystemeId'] }, '/rest/geojson_forages/import/:geoSystemeId': { newPostCall: geojsonImportForages, ...CaminoRestRoutes['/rest/geojson_forages/import/:geoSystemeId'] }, @@ -364,7 +368,7 @@ export const restWithPool = (dbPool: Pool): Router => { Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })) ) ), - Effect.tap(({ user }) => addLog(dbPool, user.id, 'post', req.url, req.body)), + Effect.tap(({ user }) => addLog(dbPool, user.id, 'post', req)), Effect.mapBoth({ onFailure: caminoError => { console.warn(`problem with route ${route}: ${caminoError.message}`) @@ -435,7 +439,7 @@ export const restWithPool = (dbPool: Pool): Router => { Effect.mapError(caminoError => ({ ...caminoError, status: HTTP_STATUS.INTERNAL_SERVER_ERROR })) ) ), - Effect.tap(({ user }) => addLog(dbPool, user.id, 'post', req.url, req.body)), + Effect.tap(({ user }) => addLog(dbPool, user.id, 'put', req)), Effect.mapBoth({ onFailure: caminoError => { console.warn(`problem with route ${route}: ${caminoError.message}`) @@ -508,10 +512,16 @@ export const restWithPool = (dbPool: Pool): Router => { ) ) }), + Effect.tap(({ user }) => addLog(dbPool, user.id, 'delete', req)), Effect.mapBoth({ onFailure: caminoError => { console.warn(`problem with route ${route}: ${caminoError.message}`) - res.status(caminoError.status).json(caminoError) + + if (!('status' in caminoError)) { + res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json(caminoError) + } else { + res.status(caminoError.status).json(caminoError) + } }, onSuccess: () => { res.sendStatus(HTTP_STATUS.NO_CONTENT) @@ -569,13 +579,13 @@ const restCatcher = (expressCall: ExpressRoute) => async (req: CaminoRequest, re next(e) } } -const restCatcherWithMutation = (method: string, expressCall: ExpressRoute, pool: Pool) => async (req: CaminoRequest, res: express.Response, next: express.NextFunction) => { +const restCatcherWithMutation = (method: LogMethod, expressCall: ExpressRoute, pool: Pool) => async (req: CaminoRequest, res: express.Response, next: express.NextFunction) => { const user = req.auth try { if (!user) { res.sendStatus(HTTP_STATUS.FORBIDDEN) } else { - await pipe(addLog(pool, user.id, method, req.url, req.body), Effect.runPromise) + await callAndExit(addLog(pool, user.id, method, req)) await expressCall(req, res, next) } } catch (e) { diff --git a/packages/common/src/date.test.ts b/packages/common/src/date.test.ts index 2485d5c25..039094f41 100644 --- a/packages/common/src/date.test.ts +++ b/packages/common/src/date.test.ts @@ -16,6 +16,7 @@ import { caminoDateValidator, CaminoDate, getDay, + dateTimeFormat, } from './date' import { test, expect } from 'vitest' @@ -31,6 +32,9 @@ test('dateFormat', () => { expect(dateFormat(null)).toBe('') expect(dateFormat(undefined)).toBe('') }) +test('dateTimeFormat', () => { + expect(dateTimeFormat(new Date('1995-12-17T03:24:00Z'))).toMatchInlineSnapshot(`"17/12/1995 04:24:00"`) +}) test('getAnnee', () => { expect(getAnnee(toCaminoDate('2022-12-01'))).toBe('2022') diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts index 976f5cfcc..37095b1ff 100644 --- a/packages/common/src/date.ts +++ b/packages/common/src/date.ts @@ -62,6 +62,8 @@ export const dateFormat = (date: CaminoDate | null | undefined): CaminoDateForma return isNullOrUndefinedOrEmpty(date) ? ('' as CaminoDateFormated) : (`${date.substring(8)}-${date.substring(5, 7)}-${date.substring(0, 4)}` as CaminoDateFormated) } +export const dateTimeFormat = (date: Date): string => date.toLocaleString('fr-FR', { timeZone: 'Europe/Paris', dateStyle: 'short', timeStyle: 'medium' }) + export const getCurrent = (): CaminoDate => toCaminoDate(new Date()) export const getCurrentAnnee = (): CaminoAnnee => getAnnee(getCurrent()) diff --git a/packages/common/src/filters.ts b/packages/common/src/filters.ts index 085962948..7f0f7ee9f 100644 --- a/packages/common/src/filters.ts +++ b/packages/common/src/filters.ts @@ -276,6 +276,7 @@ export const activitesFiltresNames = [ export const entreprisesFiltresNames = ['nomsEntreprise'] as const satisfies readonly CaminoFiltre[] +export const mutationsFiltresNames = ['emails'] as const satisfies readonly CaminoFiltre[] export const utilisateursFiltresNames = ['nomsUtilisateurs', 'emails', 'roles', 'administrationIds', 'entreprisesIds'] as const satisfies readonly CaminoFiltre[] const baseDownloadFormats = ['csv', 'xlsx', 'ods'] as const satisfies readonly DownloadFormat[] diff --git a/packages/common/src/mutations.ts b/packages/common/src/mutations.ts new file mode 100644 index 000000000..04c251ca8 --- /dev/null +++ b/packages/common/src/mutations.ts @@ -0,0 +1,26 @@ +/* v8 ignore next 8 */ +import { z } from 'zod' +import { utilisateurIdValidator } from './roles' + +const LOGS_METHODS = ['post', 'delete', 'put'] as const + +export const logsMethodValidator = z.enum(LOGS_METHODS) +export type LogMethod = z.infer<typeof logsMethodValidator> + +export const mutationIdValidator = z.string().uuid().brand('MUTATION_ID') +export type MutationId = z.infer<typeof mutationIdValidator> +export const mutationValidator = z.object({ + id: mutationIdValidator, + datetime: z.string().datetime(), + method: logsMethodValidator, + path: z.string(), + utilisateur_nom: z.string(), + utilisateur_prenom: z.string(), + utilisateur_id: utilisateurIdValidator, + camino_path: z.string(), + camino_variables: z.record(z.string(), z.string()), +}) +export type Mutation = z.infer<typeof mutationValidator> + +export const getMutationValidator = mutationValidator.extend({ body: z.string() }) +export type GetMutation = z.infer<typeof getMutationValidator> diff --git a/packages/common/src/permissions/mutation.test.ts b/packages/common/src/permissions/mutation.test.ts new file mode 100644 index 000000000..5f14f2fd8 --- /dev/null +++ b/packages/common/src/permissions/mutation.test.ts @@ -0,0 +1,7 @@ +import { test, expect } from 'vitest' +import { testBlankUser } from '../tests-utils' +import { canReadMutation } from './mutation' + +test('canReadMutation', () => { + expect(canReadMutation({ ...testBlankUser, role: 'super' })).toBe(true) +}) diff --git a/packages/common/src/permissions/mutation.ts b/packages/common/src/permissions/mutation.ts new file mode 100644 index 000000000..0705fb007 --- /dev/null +++ b/packages/common/src/permissions/mutation.ts @@ -0,0 +1,3 @@ +import { isSuper, User } from '../roles' + +export const canReadMutation = (user: User): boolean => isSuper(user) diff --git a/packages/common/src/rest.ts b/packages/common/src/rest.ts index b2117f08e..732b5eb9b 100644 --- a/packages/common/src/rest.ts +++ b/packages/common/src/rest.ts @@ -68,6 +68,7 @@ import { titreIdOrSlugValidator, titreIdValidator } from './validators/titres' import { administrationIdValidator } from './static/administrations' import { administrationActiviteTypeEmailValidator } from './administrations' import { flattenEtapeValidator, restEtapeCreationValidator, restEtapeModificationValidator } from './etape-form' +import { getMutationValidator, mutationIdValidator, mutationValidator } from './mutations' type CaminoRoute<T extends string> = { params: ZodObjectParsUrlParams<T> } & { get?: { output: ZodType; searchParams?: ZodType } @@ -129,6 +130,8 @@ const IDS = [ '/rest/geojson_points/import/:geoSystemeId', '/rest/geojson_forages/import/:geoSystemeId', '/rest/communes', + '/rest/mutations', + '/rest/mutations/:mutationId', '/deconnecter', '/changerMotDePasse', // NE PAS TOUCHER CES ROUTES, UTILISÉES PAR D'AUTRES @@ -253,6 +256,8 @@ export const CaminoRestRoutes = { params: noParamsValidator, }, '/rest/communes': { params: noParamsValidator, newGet: { output: z.array(communeValidator), searchParams: z.object({ ids: z.array(communeIdValidator).nonempty() }) } }, + '/rest/mutations': { params: noParamsValidator, newGet: { output: z.array(mutationValidator), searchParams: z.object({ emails: z.string().optional() }) } }, + '/rest/mutations/:mutationId': { params: z.object({ mutationId: mutationIdValidator }), newGet: { output: getMutationValidator } }, '/rest/geojson/import/:geoSystemeId': { params: geoSystemIdParamsValidator, newPost: { input: geojsonImportBodyValidator, output: geojsonInformationsValidator }, diff --git a/packages/ui/src/components/_common/liste.tsx b/packages/ui/src/components/_common/liste.tsx index 6a6843e34..c757e75cc 100644 --- a/packages/ui/src/components/_common/liste.tsx +++ b/packages/ui/src/components/_common/liste.tsx @@ -12,6 +12,7 @@ import { Entreprise } from 'camino-common/src/entreprise' import { CaminoRouteLocation } from '@/router/routes' import { CaminoRouter } from '@/typings/vue-router' import { PageWithFilters } from './page-with-filters' +import { CaminoError } from 'camino-common/src/zod-tools' export type Params<ColumnId extends string> = { colonne: ColumnId @@ -34,7 +35,7 @@ type ToColumnId<List> = List extends [infer First, ...infer Rest] ? (First exten type Props<ColumnId extends string, Columns> = { listeFiltre: ListeFiltreProps colonnes: Readonly<Columns> - getData: (params: Params<ToColumnId<Columns>[number]>) => Promise<{ values: TableRow<ColumnId>[]; total: number }> + getData: (params: Params<ToColumnId<Columns>[number]>) => Promise<{ values: TableRow<ColumnId>[]; total: number } | CaminoError<string>> route: CaminoRouteLocation } & PageContentHeaderProps @@ -61,7 +62,11 @@ export const Liste = defineComponent(<ColumnId extends string, Columns extends C tableData.value = { status: 'LOADING' } try { const loaded = await props.getData(params) - tableData.value = { status: 'LOADED', value: { rows: loaded.values, total: loaded.total } } + if ('message' in loaded) { + tableData.value = { status: 'NEW_ERROR', error: loaded } + } else { + tableData.value = { status: 'LOADED', value: { rows: loaded.values, total: loaded.total } } + } } catch (e: any) { console.error('error', e) tableData.value = { diff --git a/packages/ui/src/components/journaux/journaux-api-client.ts b/packages/ui/src/components/journaux/journaux-api-client.ts index d60cbf986..3b7f05627 100644 --- a/packages/ui/src/components/journaux/journaux-api-client.ts +++ b/packages/ui/src/components/journaux/journaux-api-client.ts @@ -1,12 +1,19 @@ import { apiGraphQLFetch } from '@/api/_client' +import { newGetWithJson } from '@/api/client-rest' import { Journaux, JournauxQueryParams } from 'camino-common/src/journaux' +import { GetMutation, Mutation, MutationId } from 'camino-common/src/mutations' +import { CaminoError } from 'camino-common/src/zod-tools' import gql from 'graphql-tag' export interface JournauxApiClient { getJournaux: (params: JournauxQueryParams) => Promise<Journaux> + getMutations: (email: string | undefined) => Promise<Mutation[] | CaminoError<string>> + getMutation: (mutationId: MutationId) => Promise<GetMutation | CaminoError<string>> } export const journauxApiClient: JournauxApiClient = { + getMutations: email => newGetWithJson('/rest/mutations', {}, { emails: email }), + getMutation: mutationId => newGetWithJson('/rest/mutations/:mutationId', { mutationId }), // TODO 2023-06-22 check with zod? getJournaux: async (params: JournauxQueryParams): Promise<Journaux> => apiGraphQLFetch(gql` diff --git a/packages/ui/src/components/mutation.stories.tsx b/packages/ui/src/components/mutation.stories.tsx new file mode 100644 index 000000000..b63b793e7 --- /dev/null +++ b/packages/ui/src/components/mutation.stories.tsx @@ -0,0 +1,36 @@ +import { Meta, StoryFn } from '@storybook/vue3' +import { ApiClient } from '@/api/api-client' +import { GetMutation, mutationIdValidator } from 'camino-common/src/mutations' +import { utilisateurIdValidator } from 'camino-common/src/roles' +import { PureMutation } from './mutation' + +const meta: Meta = { + title: 'Components/Mutation', + // @ts-ignore @storybook/vue3 n'aime pas les composants tsx + component: PureMutation, +} +export default meta + +const mutationId = mutationIdValidator.parse('fce835ee-a857-4fc5-9721-c542060222b3') +const element: GetMutation = { + id: mutationId, + datetime: '2025-04-07T19:00:35.824Z', + method: 'post', + path: '/rest/etapes/idEtape/depot', + utilisateur_nom: 'Nom', + utilisateur_prenom: 'Prénom', + utilisateur_id: utilisateurIdValidator.parse('anotherUserId'), + camino_path: '/rest/etapes/:etapeId/depot', + camino_variables: { etapeId: 'idEtape' }, + body: JSON.stringify({ test: 12 }), +} + +const apiClient: Pick<ApiClient, 'getMutation'> = { + getMutation: async () => { + return element + }, +} + +export const Loading: StoryFn = () => <PureMutation mutationId={mutationId} apiClient={{ ...apiClient, getMutation: () => new Promise(() => ({})) }} /> +export const WithError: StoryFn = () => <PureMutation mutationId={mutationId} apiClient={{ ...apiClient, getMutation: () => Promise.resolve({ message: 'une erreur' }) }} /> +export const Default: StoryFn = () => <PureMutation mutationId={mutationId} apiClient={apiClient} /> diff --git a/packages/ui/src/components/mutation.stories_snapshots_Default.html b/packages/ui/src/components/mutation.stories_snapshots_Default.html new file mode 100644 index 000000000..2b4f5b9f9 --- /dev/null +++ b/packages/ui/src/components/mutation.stories_snapshots_Default.html @@ -0,0 +1,9 @@ +<div class="fr-pt-8w fr-pb-4w" style="display: flex; gap: 2rem; flex-direction: column;"> + <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Date</span><span class="fr-col-12 fr-col-sm">07/04/2025 21:00:35</span></div> + <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Utilisateur</span><span class="fr-col-12 fr-col-sm">Nom Prénom</span></div> + <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Path</span><span class="fr-col-12 fr-col-sm">/rest/etapes/idEtape/depot</span></div> + <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Méthode</span><span class="fr-col-12 fr-col-sm">Post</span></div> + <div class="fr-grid-row"><span class="fr-col-12 fr-col-sm-3 fr-h6" style="margin: 0px;">Body</span><span class="fr-col-12 fr-col-sm"><pre>{ + "test": 12 +}</pre></span></div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/mutation.stories_snapshots_Loading.html b/packages/ui/src/components/mutation.stories_snapshots_Loading.html new file mode 100644 index 000000000..c5f6bb573 --- /dev/null +++ b/packages/ui/src/components/mutation.stories_snapshots_Loading.html @@ -0,0 +1,5 @@ +<div class="_top-level_3306d0" style="display: flex; justify-content: center;"> + <!----> + <!----> + <div class="_spinner_3306d0"></div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/mutation.stories_snapshots_WithError.html b/packages/ui/src/components/mutation.stories_snapshots_WithError.html new file mode 100644 index 000000000..b772b20dc --- /dev/null +++ b/packages/ui/src/components/mutation.stories_snapshots_WithError.html @@ -0,0 +1,7 @@ +<div class="" style="display: flex; justify-content: center;"> + <!----> + <div class="fr-alert fr-alert--error fr-alert--sm" role="alert"> + <p>Une erreur</p> + </div> + <!----> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/mutation.tsx b/packages/ui/src/components/mutation.tsx new file mode 100644 index 000000000..c09de64ff --- /dev/null +++ b/packages/ui/src/components/mutation.tsx @@ -0,0 +1,63 @@ +import { defineComponent, inject, onMounted } from 'vue' +import { AsyncData, asyncDataAutomaticLoad } from '@/api/client-rest' +import { GetMutation, MutationId, mutationIdValidator } from 'camino-common/src/mutations' +import { LoadingElement } from './_ui/functional-loader' +import { useState } from '@/utils/vue-tsx-utils' +import { JournauxApiClient, journauxApiClient } from './journaux/journaux-api-client' +import { computed } from 'vue' +import { useRoute } from 'vue-router' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools' +import { CaminoAccessError } from './error' +import { userKey } from '@/moi' +import { fr } from '@codegouvfr/react-dsfr' +import { LabelWithValue } from './_ui/label-with-value' +import { dateTimeFormat } from 'camino-common/src/date' + +export const Mutation = defineComponent(() => { + const route = useRoute<'mutation'>() + const user = inject(userKey) + + const mutationId = computed<MutationId | null>(() => { + const mutationId = route.params.mutationId + const validated = mutationIdValidator.safeParse(mutationId) + + if (validated.success) { + return validated.data + } + + return null + }) + + return () => <>{isNotNullNorUndefined(mutationId.value) ? <PureMutation apiClient={journauxApiClient} mutationId={mutationId.value} /> : <CaminoAccessError user={user} />}</> +}) + +interface Props { + apiClient: Pick<JournauxApiClient, 'getMutation'> + mutationId: MutationId +} +export const PureMutation = defineComponent<Props>(props => { + const [mutation, setMutation] = useState<AsyncData<GetMutation>>({ status: 'LOADING' }) + onMounted(async () => { + await asyncDataAutomaticLoad(() => props.apiClient.getMutation(props.mutationId), setMutation) + }) + + return () => ( + <LoadingElement + data={mutation.value} + renderItem={item => ( + <> + <div class={fr.cx('fr-pt-8w', 'fr-pb-4w')} style={{ display: 'flex', gap: '2rem', flexDirection: 'column' }}> + <LabelWithValue title="Date" text={dateTimeFormat(new Date(item.datetime))} /> + <LabelWithValue title="Utilisateur" text={`${item.utilisateur_nom} ${item.utilisateur_prenom}`} /> + <LabelWithValue title="Path" text={item.path} /> + <LabelWithValue title="Méthode" text={item.method} /> + <LabelWithValue title="Body" item={<pre>{JSON.stringify(JSON.parse(item.body), null, 4)}</pre>} /> + </div> + </> + )} + /> + ) +}) + +// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 +PureMutation.props = ['apiClient', 'mutationId'] diff --git a/packages/ui/src/components/mutations.stories.tsx b/packages/ui/src/components/mutations.stories.tsx new file mode 100644 index 000000000..20ca2fe23 --- /dev/null +++ b/packages/ui/src/components/mutations.stories.tsx @@ -0,0 +1,66 @@ +import { Meta, StoryFn } from '@storybook/vue3' +import { ApiClient } from '@/api/api-client' +import { action } from '@storybook/addon-actions' +import { CaminoRouteLocation } from '@/router/routes' +import { CaminoRouter } from '@/typings/vue-router' +import { PureMutations } from './mutations' +import { Mutation, mutationIdValidator } from 'camino-common/src/mutations' +import { utilisateurIdValidator } from 'camino-common/src/roles' + +const meta: Meta = { + title: 'Components/Mutations', + // @ts-ignore @storybook/vue3 n'aime pas les composants tsx + component: PureMutations, +} +export default meta + +const elements: Mutation[] = [ + { + id: mutationIdValidator.parse('a118f2bb-232f-4363-8689-42151c66bfbb'), + datetime: '', + method: 'post', + path: '', + utilisateur_nom: '', + utilisateur_prenom: '', + utilisateur_id: utilisateurIdValidator.parse('idUser'), + camino_path: '', + camino_variables: {}, + }, + + { + id: mutationIdValidator.parse('fce835ee-a857-4fc5-9721-c542060222b3'), + datetime: '2025-04-07T19:00:35.824Z', + method: 'post', + path: '/rest/etapes/idEtape/depot', + utilisateur_nom: 'Nom', + utilisateur_prenom: 'Prénom', + utilisateur_id: utilisateurIdValidator.parse('anotherUserId'), + camino_path: '/rest/etapes/:etapeId/depot', + camino_variables: { etapeId: 'idEtape' }, + }, +] + +const updateUrlQueryAction = action('updateUrlQuery') +const apiClient: Pick<ApiClient, 'titresRechercherByNom' | 'getTitresByIds' | 'getMutations'> = { + titresRechercherByNom: () => { + return Promise.resolve({ elements: [] }) + }, + getTitresByIds: () => { + return Promise.resolve({ elements: [] }) + }, + getMutations: async () => { + return elements + }, +} +const currentRoute: CaminoRouteLocation = { name: 'mutations', params: {}, query: {} } +const updateUrlQuery: Pick<CaminoRouter, 'push'> = { + push: value => { + updateUrlQueryAction(value) + return Promise.resolve() + }, +} +export const Loading: StoryFn = () => <PureMutations currentRoute={currentRoute} updateUrlQuery={updateUrlQuery} apiClient={{ ...apiClient, getMutations: () => new Promise(() => ({})) }} /> +export const WithError: StoryFn = () => ( + <PureMutations currentRoute={currentRoute} updateUrlQuery={updateUrlQuery} apiClient={{ ...apiClient, getMutations: () => Promise.resolve({ message: 'une erreur' }) }} /> +) +export const Default: StoryFn = () => <PureMutations currentRoute={currentRoute} updateUrlQuery={updateUrlQuery} apiClient={apiClient} /> diff --git a/packages/ui/src/components/mutations.stories_snapshots_Default.html b/packages/ui/src/components/mutations.stories_snapshots_Default.html new file mode 100644 index 000000000..ed6e474d2 --- /dev/null +++ b/packages/ui/src/components/mutations.stories_snapshots_Default.html @@ -0,0 +1,73 @@ +<div class="fr-container--fluid" style="overflow: visible;"> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-9 fr-pt-3w fr-pr-2w fr-pb-3w" aria-live="polite"> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Mutations</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div> + <div class="fr-table fr-table--no-caption fr-table--no-scroll" style="overflow: auto;"> + <div class="fr-table__wrapper" style="width: auto;"> + <div class="fr-table__container"> + <div class="fr-table__content"> + <table style="display: table; width: 100%;"> + <caption>Mutations</caption> + <thead> + <tr> + <th scope="col">Date</th> + <th scope="col">Utilisateur</th> + <th scope="col">Explication</th> + <th scope="col">Action</th> + </tr> + </thead> + <tbody> + <tr> + <td><a href="/mocked-href" title="Aller à la mutation a118f2bb-232f-4363-8689-42151c66bfbb" aria-label="Aller à la mutation a118f2bb-232f-4363-8689-42151c66bfbb">Invalid Date</a></td> + <td><span class=""> </span></td> + <td>Pas d'explication</td> + <td><span class="">post -> </span></td> + </tr> + <tr> + <td><a href="/mocked-href" title="Aller à la mutation fce835ee-a857-4fc5-9721-c542060222b3" aria-label="Aller à la mutation fce835ee-a857-4fc5-9721-c542060222b3">07/04/2025 21:00:35</a></td> + <td><span class="">Nom Prénom</span></td> + <td>Finalisation <a href="/mocked-href" title="Aller vers l'étape idEtape" aria-label="Aller vers l'étape idEtape">d'une étape</a></td> + <td><span class="">post -> /rest/etapes/idEtape/depot</span></td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + <!----> + </div> + </div> + <div class="fr-col-12 fr-col-md-3" style="order: -1;"> + <form class="fr-pl-2w fr-pr-2w fr-pb-3w fr-pt-3w"> + <h1 class="fr-sidemenu__title" id="fr-sidemenu-title">Filtres (2 résultats)</h1> + <div style="display: flex; flex-direction: column;"> + <div> + <div class="fr-mb-1w fr-text--bold fr-text--md"><label for="filtres_input_emails">Emails</label></div> + <div class="fr-pb-2w fr-fieldset__element fr-mb-0 fr-pt-0"> + <div class="fr-input-group" style="margin-bottom: 0px;"> + <!----><input placeholder="prenom.nom@domaine.fr, ..." class="fr-input" name="filtres_input_emails" id="filtres_input_emails" type="text" value=""> + <!----> + </div> + <!----> + <!----> + <!----> + </div> + </div> + </div> + <div style="display: flex; justify-content: end;" class="fr-mt-2w"><button class="fr-btn fr-btn--secondary fr-btn--md" title="Réinitialiser les filtres" aria-label="Réinitialiser les filtres" type="button">Réinitialiser les filtres</button></div> + </form> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/mutations.stories_snapshots_Loading.html b/packages/ui/src/components/mutations.stories_snapshots_Loading.html new file mode 100644 index 000000000..db288ecb2 --- /dev/null +++ b/packages/ui/src/components/mutations.stories_snapshots_Loading.html @@ -0,0 +1,125 @@ +<div class="fr-container--fluid" style="overflow: visible;"> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-9 fr-pt-3w fr-pr-2w fr-pb-3w" aria-live="polite"> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Mutations</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div> + <div class="fr-table fr-table--no-caption fr-table--no-scroll" style="overflow: auto;"> + <div class="fr-table__wrapper" style="width: auto;"> + <div class="fr-table__container"> + <div class="fr-table__content"> + <table style="display: table; width: 100%;"> + <caption>Mutations</caption> + <thead> + <tr> + <th scope="col">Date</th> + <th scope="col">Utilisateur</th> + <th scope="col">Explication</th> + <th scope="col">Action</th> + </tr> + </thead> + <tbody> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + <div class="_top-level_3306d0" style="display: flex; justify-content: center;"> + <!----> + <!----> + <div class="_spinner_3306d0"></div> + </div> + </div> + </div> + <div class="fr-col-12 fr-col-md-3" style="order: -1;"> + <form class="fr-pl-2w fr-pr-2w fr-pb-3w fr-pt-3w"> + <h1 class="fr-sidemenu__title" id="fr-sidemenu-title">Filtres ...</h1> + <div style="display: flex; flex-direction: column;"> + <div> + <div class="fr-mb-1w fr-text--bold fr-text--md"><label for="filtres_input_emails">Emails</label></div> + <div class="fr-pb-2w fr-fieldset__element fr-mb-0 fr-pt-0"> + <div class="fr-input-group" style="margin-bottom: 0px;"> + <!----><input placeholder="prenom.nom@domaine.fr, ..." class="fr-input" name="filtres_input_emails" id="filtres_input_emails" type="text" value=""> + <!----> + </div> + <!----> + <!----> + <!----> + </div> + </div> + </div> + <div style="display: flex; justify-content: end;" class="fr-mt-2w"><button class="fr-btn fr-btn--secondary fr-btn--md" title="Réinitialiser les filtres" aria-label="Réinitialiser les filtres" type="button">Réinitialiser les filtres</button></div> + </form> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/mutations.stories_snapshots_WithError.html b/packages/ui/src/components/mutations.stories_snapshots_WithError.html new file mode 100644 index 000000000..5d2941c4e --- /dev/null +++ b/packages/ui/src/components/mutations.stories_snapshots_WithError.html @@ -0,0 +1,127 @@ +<div class="fr-container--fluid" style="overflow: visible;"> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-9 fr-pt-3w fr-pr-2w fr-pb-3w" aria-live="polite"> + <div class="fr-grid-row"> + <div class="fr-col-12 fr-col-md-6"> + <h1>Mutations</h1> + </div> + <div class="fr-col-12 fr-col-md-6" style="display: flex; flex-direction: row; align-items: flex-start; justify-content: flex-end;"> + <!----> + <!----> + </div> + </div> + <div> + <div> + <div class="fr-table fr-table--no-caption fr-table--no-scroll" style="overflow: auto;"> + <div class="fr-table__wrapper" style="width: auto;"> + <div class="fr-table__container"> + <div class="fr-table__content"> + <table style="display: table; width: 100%;"> + <caption>Mutations</caption> + <thead> + <tr> + <th scope="col">Date</th> + <th scope="col">Utilisateur</th> + <th scope="col">Explication</th> + <th scope="col">Action</th> + </tr> + </thead> + <tbody> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + <tr> + <td>...</td> + <td>...</td> + <td>...</td> + <td>...</td> + </tr> + </tbody> + </table> + </div> + </div> + </div> + </div> + </div> + <div class="" style="display: flex; justify-content: center;"> + <!----> + <div class="fr-alert fr-alert--error fr-alert--sm" role="alert"> + <p>Une erreur</p> + </div> + <!----> + </div> + </div> + </div> + <div class="fr-col-12 fr-col-md-3" style="order: -1;"> + <form class="fr-pl-2w fr-pr-2w fr-pb-3w fr-pt-3w"> + <h1 class="fr-sidemenu__title" id="fr-sidemenu-title">Filtres ...</h1> + <div style="display: flex; flex-direction: column;"> + <div> + <div class="fr-mb-1w fr-text--bold fr-text--md"><label for="filtres_input_emails">Emails</label></div> + <div class="fr-pb-2w fr-fieldset__element fr-mb-0 fr-pt-0"> + <div class="fr-input-group" style="margin-bottom: 0px;"> + <!----><input placeholder="prenom.nom@domaine.fr, ..." class="fr-input" name="filtres_input_emails" id="filtres_input_emails" type="text" value=""> + <!----> + </div> + <!----> + <!----> + <!----> + </div> + </div> + </div> + <div style="display: flex; justify-content: end;" class="fr-mt-2w"><button class="fr-btn fr-btn--secondary fr-btn--md" title="Réinitialiser les filtres" aria-label="Réinitialiser les filtres" type="button">Réinitialiser les filtres</button></div> + </form> + </div> + </div> +</div> \ No newline at end of file diff --git a/packages/ui/src/components/mutations.tsx b/packages/ui/src/components/mutations.tsx new file mode 100644 index 000000000..11d5b61f0 --- /dev/null +++ b/packages/ui/src/components/mutations.tsx @@ -0,0 +1,261 @@ +import { defineComponent, FunctionalComponent } from 'vue' +import { Column, TableRow } from './_ui/table' +import { useRouter } from 'vue-router' +import { Liste, Params } from './_common/liste' +import { ApiClient, apiClient } from '@/api/api-client' + +import { CaminoRouteLocation, routesDefinitions } from '@/router/routes' +import { CaminoRouter } from '@/typings/vue-router' +import { Mutation } from 'camino-common/src/mutations' +import { DeleteRestRoutes, NewDeleteRestRoutes, NewPostRestRoutes, NewPutRestRoutes } from 'camino-common/src/rest' +import { CaminoRouterLink } from '@/router/camino-router-link' +import { dateTimeFormat } from 'camino-common/src/date' + +export const Mutations = defineComponent(() => { + const router = useRouter() + return () => <PureMutations apiClient={apiClient} updateUrlQuery={router} currentRoute={router.currentRoute.value} /> +}) + +interface Props { + apiClient: Pick<ApiClient, 'getMutations' | 'titresRechercherByNom' | 'getTitresByIds'> + currentRoute: CaminoRouteLocation + updateUrlQuery: Pick<CaminoRouter, 'push'> +} + +const colonnesData = [ + { id: 'date', contentTitle: 'Date', noSort: true }, + { id: 'utilisateur', contentTitle: 'Utilisateur', noSort: true }, + { id: 'explication', contentTitle: 'Explication', noSort: true }, + { id: 'detail', contentTitle: 'Action', noSort: true }, +] as const satisfies Column[] + +type ColonneId = (typeof colonnesData)[number]['id'] + +// ici on veut avoir un match exhaustif de toutes les routes connues, mais les anciennes routes doivent quand même fonctionner sans faire planter l'application +const exhaustiveCheckPass = (_param: never): never => { + return "Pas d'explication" as never +} + +const PathToExplanation: FunctionalComponent<{ mutation: Mutation }> = props => { + const baseText = props.mutation.method === 'delete' ? 'Suppression ' : props.mutation.method === 'put' ? 'Modification ' : 'Création ' + const caminoPath = props.mutation.camino_path as NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes + switch (caminoPath) { + case '/rest/activites/:activiteId': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers l'activité ${props.mutation.camino_variables.activiteId}`} + isDisabled={false} + to={{ name: 'activite', params: { activiteId: props.mutation.camino_variables.activiteId } }} + > + d'une activité + </CaminoRouterLink> + </> + ) + case '/rest/demarches': + return <>{baseText} d'une démarche</> + case '/rest/etapes': + return <>{baseText} d'une étape</> + case '/rest/titres': + return <>{baseText} d'un titre</> + case '/rest/entreprises': + return <>{baseText} d'une entreprise</> + case '/rest/demarches/:demarcheIdOrSlug': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers la démarche ${props.mutation.camino_variables.demarcheIdOrSlug}`} + isDisabled={false} + to={{ name: 'demarche', params: { demarcheId: props.mutation.camino_variables.demarcheIdOrSlug } }} + > + d'une démarche + </CaminoRouterLink> + </> + ) + case '/rest/titres/:titreId': + return ( + <> + {baseText}{' '} + <CaminoRouterLink title={`Aller vers le titre ${props.mutation.camino_variables.titreId}`} isDisabled={false} to={{ name: 'titre', params: { id: props.mutation.camino_variables.titreId } }}> + d'un titre + </CaminoRouterLink> + </> + ) + case '/rest/etapes/:etapeIdOrSlug': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers l'étape ${props.mutation.camino_variables.etapeIdOrSlug}`} + isDisabled={false} + to={{ name: 'etape', params: { id: props.mutation.camino_variables.etapeIdOrSlug } }} + > + d'une étape + </CaminoRouterLink> + </> + ) + case '/rest/etapes/:etapeId/depot': + return ( + <> + Finalisation{' '} + <CaminoRouterLink title={`Aller vers l'étape ${props.mutation.camino_variables.etapeId}`} isDisabled={false} to={{ name: 'etape', params: { id: props.mutation.camino_variables.etapeId } }}> + d'une étape + </CaminoRouterLink> + </> + ) + case '/rest/entreprises/:entrepriseId': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers l'entreprise ${props.mutation.camino_variables.entrepriseId}`} + isDisabled={false} + to={{ name: 'entreprise', params: { id: props.mutation.camino_variables.entrepriseId } }} + > + d'une entreprise + </CaminoRouterLink> + </> + ) + case '/rest/entreprises/:entrepriseId/documents': + case '/rest/entreprises/:entrepriseId/documents/:entrepriseDocumentId': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers l'entreprise ${props.mutation.camino_variables.entrepriseId}`} + isDisabled={false} + to={{ name: 'entreprise', params: { id: props.mutation.camino_variables.entrepriseId } }} + > + d'un document d'entreprise + </CaminoRouterLink> + </> + ) + case '/rest/titres/:id/titreLiaisons': + return ( + <> + {baseText}{' '} + <CaminoRouterLink title={`Aller vers le titre ${props.mutation.camino_variables.id}`} isDisabled={false} to={{ name: 'titre', params: { id: props.mutation.camino_variables.id } }}> + d'un lien entre deux titres + </CaminoRouterLink> + </> + ) + case '/rest/administrations/:administrationId/activiteTypeEmails': + case '/rest/administrations/:administrationId/activiteTypeEmails/delete': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers l'administration ${props.mutation.camino_variables.administrationId}`} + isDisabled={false} + to={{ name: 'administration', params: { id: props.mutation.camino_variables.administrationId } }} + > + d'un email pour l'activité d'une administration + </CaminoRouterLink> + </> + ) + case '/rest/titres/:titreId/abonne': + return ( + <> + {baseText}{' '} + <CaminoRouterLink title={`Aller vers le titre ${props.mutation.camino_variables.titreId}`} isDisabled={false} to={{ name: 'titre', params: { id: props.mutation.camino_variables.titreId } }}> + d'un abonnement sur un titre + </CaminoRouterLink> + </> + ) + case '/rest/utilisateurs/:id/permission': + return ( + <> + {baseText}{' '} + <CaminoRouterLink + title={`Aller vers l'utilisateur ${props.mutation.camino_variables.id}`} + isDisabled={false} + to={{ name: 'utilisateur', params: { id: props.mutation.camino_variables.id } }} + > + des permissions d'un utilisateur + </CaminoRouterLink> + </> + ) + case '/rest/utilisateur/generateQgisToken': + case '/rest/geojson/import/:geoSystemeId': + case '/rest/geojson_forages/import/:geoSystemeId': + case '/rest/geojson_points/import/:geoSystemeId': + return <></> + default: + return exhaustiveCheckPass(caminoPath) + } +} + +const lignes = (mutations: Mutation[]): TableRow<ColonneId>[] => { + return mutations.map(mutation => { + const columns: TableRow<ColonneId>['columns'] = { + date: { + type: 'jsx', + jsxElement: ( + <CaminoRouterLink title={`Aller à la mutation ${mutation.id}`} isDisabled={false} to={{ name: 'mutation', params: { mutationId: mutation.id } }}> + {dateTimeFormat(new Date(mutation.datetime))} + </CaminoRouterLink> + ), + value: mutation.datetime, + }, + utilisateur: { + type: 'text', + value: `${mutation.utilisateur_nom} ${mutation.utilisateur_prenom}`, + }, + explication: { + type: 'jsx', + jsxElement: <PathToExplanation mutation={mutation} />, + value: mutation.id, + }, + detail: { + type: 'text', + value: `${mutation.method} -> ${mutation.path}`, + }, + } + + return { + id: mutation.id, + link: null, + columns, + } + }) +} + +const filteredPaths = [ + '/rest/utilisateur/generateQgisToken', + '/rest/geojson/import/:geoSystemeId', + '/rest/geojson_forages/import/:geoSystemeId', + '/rest/geojson_points/import/:geoSystemeId', +] as const satisfies (NewPostRestRoutes | NewPutRestRoutes | DeleteRestRoutes | NewDeleteRestRoutes)[] +export const PureMutations = defineComponent<Props>(props => { + const getData = async (event: Params<string>) => { + const values = await props.apiClient.getMutations(event.filtres?.emails) + if ('message' in values) { + return values + } + + const filteredValue = values.filter(value => !filteredPaths.includes(value.camino_path)) + + return { total: filteredValue.length, values: lignes(filteredValue) } + } + + return () => ( + <Liste + listeFiltre={{ + filtres: routesDefinitions[props.currentRoute.name].meta.filtres, + apiClient: props.apiClient, + updateUrlQuery: props.updateUrlQuery, + entreprises: [], + }} + renderButton={null} + download={null} + colonnes={colonnesData} + route={props.currentRoute} + getData={getData} + nom="Mutations" + /> + ) +}) +// @ts-ignore waiting for https://github.com/vuejs/core/issues/7833 +PureMutations.props = ['apiClient', 'currentRoute', 'updateUrlQuery'] diff --git a/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html b/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html index 14bd9b262..bfcc07ec2 100644 --- a/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html +++ b/packages/ui/src/components/page/header.stories_snapshots_CanOpenAnnuaire.html @@ -56,6 +56,7 @@ </div> </li> <li class="fr-nav__item"><a class="fr-nav__link" to="{name:journaux}" target="_self" type="primary">Journaux</a></li> + <li class="fr-nav__item"><a class="fr-nav__link" to="{name:mutations}" target="_self" type="primary">Mutations</a></li> </ul> </nav> </div> diff --git a/packages/ui/src/components/page/header.stories_snapshots_Super.html b/packages/ui/src/components/page/header.stories_snapshots_Super.html index 14bd9b262..bfcc07ec2 100644 --- a/packages/ui/src/components/page/header.stories_snapshots_Super.html +++ b/packages/ui/src/components/page/header.stories_snapshots_Super.html @@ -56,6 +56,7 @@ </div> </li> <li class="fr-nav__item"><a class="fr-nav__link" to="{name:journaux}" target="_self" type="primary">Journaux</a></li> + <li class="fr-nav__item"><a class="fr-nav__link" to="{name:mutations}" target="_self" type="primary">Mutations</a></li> </ul> </nav> </div> diff --git a/packages/ui/src/components/page/menu.ts b/packages/ui/src/components/page/menu.ts index 9f26161f3..cd1c0b053 100644 --- a/packages/ui/src/components/page/menu.ts +++ b/packages/ui/src/components/page/menu.ts @@ -13,6 +13,7 @@ const links = { UTILISATEURS: { label: 'Utilisateurs', path: 'utilisateurs' }, ADMINISTRATIONS: { label: 'Administrations', path: 'administrations' }, JOURNAUX: { label: 'Journaux', path: 'journaux' }, + MUTATIONS: { label: 'Mutations', path: 'mutations' }, TABLEAU_DE_BORD: { label: 'Tableau de bord', path: 'dashboard' }, } as const satisfies Record<string, Link> @@ -24,7 +25,7 @@ export const linksByRole = (user: User): Record<Role, (Link | Annuaire)[]> => { const linkActivites: Link[] = canReadActivites(user) ? [links.ACTIVITES] : [] return { - super: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE, links.JOURNAUX], + super: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE, links.JOURNAUX, links.MUTATIONS], admin: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE], editeur: [links.TABLEAU_DE_BORD, links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, ...linkActivites, links.STATISTIQUES, ANNUAIRE], lecteur: [links.TITRES_ET_AUTORISATIONS, links.DEMARCHES, links.TRAVAUX, links.STATISTIQUES, ANNUAIRE], diff --git a/packages/ui/src/components/page/plan.stories_snapshots_Super.html b/packages/ui/src/components/page/plan.stories_snapshots_Super.html index 0d3b91c0c..fc410ce82 100644 --- a/packages/ui/src/components/page/plan.stories_snapshots_Super.html +++ b/packages/ui/src/components/page/plan.stories_snapshots_Super.html @@ -14,5 +14,6 @@ </ul> </li> <li><a href="/mocked-href" title="Journaux" aria-label="Journaux">Journaux</a></li> + <li><a href="/mocked-href" title="Mutations" aria-label="Mutations">Mutations</a></li> </ul> </div> \ No newline at end of file diff --git a/packages/ui/src/router/index.ts b/packages/ui/src/router/index.ts index 59aadfbc7..7289bb2d2 100644 --- a/packages/ui/src/router/index.ts +++ b/packages/ui/src/router/index.ts @@ -115,13 +115,25 @@ const Journaux = async () => { return Journaux } +const Mutations = async () => { + const { Mutations } = await import('../components/mutations') + + return Mutations +} + +const Mutation = async () => { + const { Mutation } = await import('../components/mutation') + + return Mutation +} + const About = async () => { const { About } = await import('../components/content/about') return About } -export type MenuSection = 'dashboard' | 'titres' | 'demarches' | 'travaux' | 'activites' | 'administrations' | 'entreprises' | 'utilisateurs' | 'statistiques' | 'journaux' +export type MenuSection = 'dashboard' | 'titres' | 'demarches' | 'travaux' | 'activites' | 'administrations' | 'entreprises' | 'utilisateurs' | 'statistiques' | 'journaux' | 'mutations' declare module 'vue-router' { interface RouteMeta { @@ -221,6 +233,15 @@ const routes = { ...routesDefinitions.journaux, component: Journaux, }, + + mutations: { + ...routesDefinitions.mutations, + component: Mutations, + }, + mutation: { + ...routesDefinitions.mutation, + component: Mutation, + }, // url /stats : demande de Samuel // pour avoir une uniformité entre toutes les start-ups statistiquesbetagouv: { diff --git a/packages/ui/src/router/routes.ts b/packages/ui/src/router/routes.ts index 2864313da..9682391f9 100644 --- a/packages/ui/src/router/routes.ts +++ b/packages/ui/src/router/routes.ts @@ -1,4 +1,4 @@ -import { activitesFiltresNames, CaminoFiltre, entreprisesFiltresNames, titresFiltresNames, utilisateursFiltresNames } from 'camino-common/src/filters' +import { activitesFiltresNames, CaminoFiltre, entreprisesFiltresNames, mutationsFiltresNames, titresFiltresNames, utilisateursFiltresNames } from 'camino-common/src/filters' import type { LocationQueryRaw, RouteRecordRaw } from 'vue-router' import { RouteMeta } from 'vue-router' const demarchesFiltres = [ @@ -31,7 +31,7 @@ const travauxFiltres = [ const administrationsFiltres: readonly CaminoFiltre[] = ['nomsAdministration', 'administrationTypesIds'] as const // prettier-ignore -const ROUTES = ['dashboard','statsDGTM','titres','titreCreation','titre','demarches','demarche','travaux','etape','etapeCreation','etapeEdition','resultatMiseEnConcurrence','utilisateurs','utilisateur','entreprises','entreprise','administrations','administration','activites','activite','activiteEdition','statistiques','journaux','statistiquesbetagouv','aPropos','plan', 'homepage','erreur' ] as const +const ROUTES = ['dashboard','statsDGTM','titres','titreCreation','titre','demarches','demarche','travaux','etape','etapeCreation','etapeEdition','resultatMiseEnConcurrence','utilisateurs','utilisateur','entreprises','entreprise','administrations','administration','activites','activite','activiteEdition','statistiques','journaux','mutations','mutation','statistiquesbetagouv','aPropos','plan', 'homepage','erreur' ] as const type CaminoRoute<T extends CaminoRouteNames> = Pick<RouteRecordRaw, 'path'> & { name: T; meta: RouteMeta } export const routesDefinitions = { dashboard: { @@ -215,6 +215,7 @@ export const routesDefinitions = { filtres: [], }, }, + activiteEdition: { path: '/activites/:activiteId/edition', name: 'activiteEdition', @@ -242,6 +243,24 @@ export const routesDefinitions = { filtres: ['titresIds'], }, }, + mutations: { + path: '/mutations', + name: 'mutations', + meta: { + menuSection: 'mutations', + title: 'Mutations', + filtres: mutationsFiltresNames, + }, + }, + mutation: { + path: '/mutation/:mutationId', + name: 'mutation', + meta: { + title: 'Détail de la mutation {{ mutationId }}', + menuSection: 'mutations', + filtres: [], + }, + }, // url /stats : demande de Samuel // pour avoir une uniformité entre toutes les start-ups statistiquesbetagouv: { -- GitLab