feat(r1.2): écrans web du référentiel — 7 écrans fidèles à maquette-r1

- Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions
  PostGIS), création en modale avec position posée au clic sur la carte
- Fiche site : arbre site→zones, appareils rattachés, ajout de zone
- Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ;
  fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création
  identité → rattachement → organes ; étiquette A6 imprimable (objet
  papier, window.print n'imprime qu'elle)
- Personnes & équipes : invitation → lien d'activation affiché à copier
  (pas d'email en R1, décision explicite), renvoi de lien, équipes ;
  page /activation (choix du mot de passe, connexion directe)
- Catégories : ajout, renommage inline, désactivation
- navigation et actions pilotées par la matrice (usePermissions) —
  l'API re-vérifie chaque requête
- e2e Playwright : recette R1 officielle rejouée intégralement (site →
  zone → appareil+organe → étiquette → invitation → activation) +
  parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup
- alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-16 13:06:45 +01:00
parent 266ffaaf1b
commit 9778629827
29 changed files with 2350 additions and 35 deletions

View File

@@ -0,0 +1,190 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type {
AssetComponentCreate,
AssetCreate,
AssetUpdate,
CategoryCreate,
CategoryUpdate,
InvitationCreate,
LocationCreate,
TeamCreate,
UserUpdate,
} from '@siop/shared';
import { api } from './client';
/** Hooks R1 — chaque appel passe par le client typé (règle d'or). */
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
if (res.error || res.data === undefined) {
const message =
(res.error as { message?: string } | undefined)?.message ??
`Le serveur a répondu ${res.response.status}`;
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
}
return res.data;
}
export function useLocations() {
return useQuery({
queryKey: ['locations'],
queryFn: async () => (await unwrap(await api.GET('/locations'))).locations,
});
}
export function useAssets() {
return useQuery({
queryKey: ['assets'],
queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
});
}
export function useAsset(id: string | undefined) {
return useQuery({
queryKey: ['assets', id],
enabled: !!id,
queryFn: async () =>
unwrap(await api.GET('/assets/{id}', { params: { path: { id: id! } } })),
});
}
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: async () => (await unwrap(await api.GET('/categories'))).categories,
});
}
export function useTeams() {
return useQuery({
queryKey: ['teams'],
queryFn: async () => (await unwrap(await api.GET('/teams'))).teams,
});
}
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
});
}
export function useRoles() {
return useQuery({
queryKey: ['roles'],
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
});
}
function useInvalidate(...keys: string[]) {
const queryClient = useQueryClient();
return () =>
Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] })));
}
export function useCreateLocation() {
const invalidate = useInvalidate('locations');
return useMutation({
mutationFn: async (body: LocationCreate) =>
unwrap(await api.POST('/locations', { body })),
onSuccess: invalidate,
});
}
export function useCreateAsset() {
const invalidate = useInvalidate('assets', 'locations');
return useMutation({
mutationFn: async (body: AssetCreate) => unwrap(await api.POST('/assets', { body })),
onSuccess: invalidate,
});
}
export function useUpdateAsset(id: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (body: AssetUpdate) =>
unwrap(await api.PATCH('/assets/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useAddComponent(assetId: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (body: AssetComponentCreate) =>
unwrap(
await api.POST('/assets/{id}/components', {
params: { path: { id: assetId } },
body,
}),
),
onSuccess: invalidate,
});
}
export function useRemoveComponent(assetId: string) {
const invalidate = useInvalidate('assets');
return useMutation({
mutationFn: async (componentId: string) => {
const res = await api.DELETE('/assets/{id}/components/{componentId}', {
params: { path: { id: assetId, componentId } },
});
if (res.response.status >= 400) throw new Error('Suppression impossible');
},
onSuccess: invalidate,
});
}
export function useCreateCategory() {
const invalidate = useInvalidate('categories');
return useMutation({
mutationFn: async (body: CategoryCreate) =>
unwrap(await api.POST('/categories', { body })),
onSuccess: invalidate,
});
}
export function useUpdateCategory() {
const invalidate = useInvalidate('categories');
return useMutation({
mutationFn: async ({ id, ...body }: CategoryUpdate & { id: string }) =>
unwrap(await api.PATCH('/categories/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useCreateTeam() {
const invalidate = useInvalidate('teams', 'users');
return useMutation({
mutationFn: async (body: TeamCreate) => unwrap(await api.POST('/teams', { body })),
onSuccess: invalidate,
});
}
export function useInviteUser() {
const invalidate = useInvalidate('users');
return useMutation({
mutationFn: async (body: InvitationCreate) =>
unwrap(await api.POST('/users/invitations', { body })),
onSuccess: invalidate,
});
}
export function useResendInvitation() {
const invalidate = useInvalidate('users');
return useMutation({
mutationFn: async (userId: string) =>
unwrap(
await api.POST('/users/{id}/invitation', { params: { path: { id: userId } } }),
),
onSuccess: invalidate,
});
}
export function useUpdateUser() {
const invalidate = useInvalidate('users', 'teams');
return useMutation({
mutationFn: async ({ id, ...body }: UserUpdate & { id: string }) =>
unwrap(await api.PATCH('/users/{id}', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}