@movalib/movalib-commons 1.68.32 → 1.68.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,74 @@
1
+ # Feature : Minimiser la dialog de création de RDV (MyCalendarEventDialog)
2
+
3
+ ## Contexte
4
+
5
+ L'application movalib-pro (React/TypeScript + MUI) possède une grosse dialog de création/édition de rendez-vous : `MyCalendarEventDialog`. Actuellement, si l'utilisateur ferme la dialog en cours de saisie, tout est perdu.
6
+
7
+ ## Objectif
8
+
9
+ Permettre de **minimiser** la dialog de création de RDV (façon Gmail compose / fenêtres Windows) pour la reprendre plus tard, sans perdre la saisie en cours. Les dialogs minimisées apparaissent sous forme de chips/onglets dans une barre fixée en bas à droite de l'écran.
10
+
11
+ ## Règles métier
12
+
13
+ 1. **Uniquement en mode CRÉATION.** `MyCalendarEventDialog` peut aussi s'ouvrir en mode édition : dans ce cas, PAS de bouton minimiser, comportement actuel inchangé.
14
+ 2. **Maximum 3 brouillons minimisés simultanément.** Si l'utilisateur tente d'ouvrir une 4e création alors que 3 brouillons existent déjà : bloquer l'ouverture et afficher un snackbar d'information (ex: "Vous avez atteint la limite de 3 brouillons de rendez-vous. Fermez ou finalisez un brouillon existant."). Utiliser le système de snackbar existant du projet.
15
+ 3. **Une seule dialog visible à la fois.** Restaurer un brouillon alors qu'une dialog est ouverte → minimiser automatiquement la dialog courante (si en mode création) avant de restaurer l'autre. Si la dialog courante est en mode édition, bloquer avec snackbar.
16
+ 4. **Label du chip minimisé** : nom du client si saisi, sinon plaque d'immatriculation si saisie, sinon "Brouillon RDV". Mettre à jour le label au moment de la minimisation (pas besoin de temps réel).
17
+ 5. **Persistance au refresh (F5)** : les brouillons doivent survivre à un rechargement de page via `localStorage` (voir section Persistance).
18
+ 6. La **fermeture définitive** (croix / annuler / création réussie du RDV) supprime le brouillon (state + localStorage).
19
+
20
+ ## Architecture attendue
21
+
22
+ ### Stratégie : composant gardé monté + snapshot localStorage
23
+
24
+ - **En session** : quand une dialog est minimisée, NE PAS démonter le composant. Le cacher visuellement (wrapper avec `display: none`, ou garder la Dialog MUI avec `open=false` selon ce qui préserve le mieux le state — vérifier que MUI ne démonte pas le contenu : utiliser `keepMounted` sur la Dialog si nécessaire). Le state interne (Formik, refs, etc.) est ainsi préservé sans sérialisation.
25
+ - **Pour le refresh** : en complément, sauvegarder un snapshot sérialisable des valeurs du formulaire dans `localStorage` à chaque minimisation (et idéalement de façon debounced pendant la saisie si simple à faire, sinon uniquement à la minimisation).
26
+
27
+ ### Composants / fichiers à créer
28
+
29
+ 1. **`MinimizedEventDialogsContext`** (Context + Provider + hook `useMinimizedEventDialogs`)
30
+ - State : liste des brouillons `{ id: string, label: string, createdAt: number }`, `activeDraftId: string | null`
31
+ - Actions : `openNewDraft()` (retourne null + snackbar si limite atteinte), `minimizeDraft(id, label, formSnapshot)`, `restoreDraft(id)`, `closeDraft(id)`
32
+ - Gère la synchronisation avec localStorage.
33
+
34
+ 2. **`MinimizedDialogsBar`** — barre fixe en bas à droite (`position: fixed; bottom; right; zIndex` au-dessus du contenu mais sous les dialogs/snackbars MUI). Affiche un chip MUI par brouillon :
35
+ - Clic sur le chip → restaure le brouillon
36
+ - Petite croix sur le chip (`onDelete` du Chip MUI) → fermeture définitive du brouillon, avec dialog de confirmation ("Supprimer ce brouillon ?") via le système de confirmation existant du projet
37
+ - La barre n'est pas rendue si aucun brouillon.
38
+
39
+ 3. **Intégration dans `MyCalendarEventDialog`** :
40
+ - Ajouter un **IconButton "Réduire"** (icône `Minimize` ou `Remove` de @mui/icons-material) dans la **toolbar existante de la dialog, juste à côté du bouton close**, uniquement en mode création. Respecter le style existant de la toolbar (taille, couleur des icons).
41
+ - Au clic : calculer le label (client > plaque > "Brouillon RDV"), snapshot des valeurs du formulaire, appeler `minimizeDraft`.
42
+ - Chaque instance de dialog en création reçoit un `draftId` (uuid généré à l'ouverture).
43
+
44
+ 4. **Rendu des instances** : le composant parent qui monte `MyCalendarEventDialog` (probablement le composant calendrier) doit pouvoir rendre jusqu'à 3 instances simultanées (une par brouillon), chacune cachée ou visible selon `activeDraftId`. Adapter le point de montage actuel en conséquence (ex: mapper sur la liste des brouillons du context). Analyser d'abord comment la dialog est actuellement ouverte (state local ? context existant type DocumentEditorContext ?) et s'intégrer proprement dans le pattern existant — modification chirurgicale, pas de refactoring massif.
45
+
46
+ ### Persistance localStorage
47
+
48
+ - Clé : `movalib.rdvDrafts` (ou préfixe cohérent avec les conventions existantes du projet — vérifier s'il y a déjà des clés localStorage et suivre le même pattern).
49
+ - Contenu : `[{ id, label, createdAt, formValues }]` où `formValues` est un snapshot JSON-sérialisable des champs du formulaire.
50
+ - **Attention aux types non sérialisables** : dates (stocker en ISO string, reconvertir au restore), fichiers/pièces jointes (NE PAS persister — accepter la perte au refresh, éventuellement le signaler), instances de classes (class-transformer : utiliser `instanceToPlain`/`plainToInstance` si le form utilise des modèles de classe).
51
+ - Au chargement de l'app : lire localStorage, recréer les entrées de brouillons dans le context (dialogs montées cachées, formulaire réhydraté avec les snapshots).
52
+ - Prévoir une expiration simple : purger les brouillons de plus de 7 jours au chargement.
53
+ - Versionner le schéma (`{ version: 1, drafts: [...] }`) : si le schéma ne matche pas, purger silencieusement (pas de crash).
54
+
55
+ ## Étapes suggérées
56
+
57
+ 1. **Explorer** : localiser `MyCalendarEventDialog`, comprendre comment elle est ouverte (props, state parent, mode création vs édition), identifier le système de formulaire (Formik ?), le système snackbar et le système de dialog de confirmation existants.
58
+ 2. Créer le context + provider, brancher au niveau racine approprié (au même niveau que les providers existants).
59
+ 3. Créer `MinimizedDialogsBar`.
60
+ 4. Adapter le montage de `MyCalendarEventDialog` pour supporter les instances multiples cachées + le bouton Réduire.
61
+ 5. Ajouter la persistance localStorage (snapshot + réhydratation).
62
+ 6. Vérifier : build TypeScript OK, pas de régression sur le mode édition, la limite de 3 fonctionne, le F5 restaure bien.
63
+
64
+ ## Contraintes
65
+
66
+ - Respecter les conventions existantes du projet (MUI theming, structure des dossiers, i18n si les libellés passent par un système de traduction — vérifier).
67
+ - Modifications minimales et chirurgicales sur `MyCalendarEventDialog` : la logique de minimisation doit vivre au maximum dans le context et le wrapper, pas dans le corps de la dialog.
68
+ - Aucun changement de comportement pour le mode édition.
69
+ - Tous les libellés en français.
70
+
71
+ ## Questions à poser si ambigu
72
+
73
+ - Si le formulaire n'utilise pas Formik ou si le state est éclaté dans plusieurs sous-composants, proposer la stratégie de snapshot avant d'implémenter.
74
+ - Si `MyCalendarEventDialog` est montée via un portal/context existant, montrer le plan d'intégration avant de modifier.
@@ -0,0 +1,392 @@
1
+ # MOVA-393 — Export FEC incrémentiel (écritures comptables)
2
+
3
+ ## But de la tâche
4
+
5
+ Implémenter, dans **movalib-pro**, un écran de **gestion des exports comptables (FEC)**
6
+ accessible depuis le module **Facturation**. L'utilisateur doit pouvoir :
7
+
8
+ - voir le **nombre d'écritures en attente d'export** (WIP) ;
9
+ - **générer un export incrémentiel** à tout moment (seules les écritures non encore consommées) ;
10
+ - **suivre l'avancement** de la génération (et voir un échec) ;
11
+ - **télécharger** les exports disponibles ;
12
+ - **identifier** les exports déjà téléchargés ;
13
+ - **annuler** le dernier export généré s'il n'a pas encore été téléchargé ;
14
+ - **consulter tout l'historique**, avec pagination (potentiellement volumineux).
15
+
16
+ > ⚠️ **La maquette HTML fournie n'est pas à reprendre telle quelle.** Elle sert
17
+ > **uniquement de référence pour l'ordre et la présence des blocs**. Le rendu doit
18
+ > respecter la **DA (design system) réelle de movalib-pro** : MUI v6, thème existant
19
+ > (`src/theme`), composants et patterns déjà en place. Aucune couleur / police / valeur
20
+ > en dur issue de la maquette. Voir la section « Correspondance maquette → DA ».
21
+
22
+ ---
23
+
24
+ ## Emplacements des fichiers à créer
25
+
26
+ ```
27
+ src/pages/billing/AccountingExports.tsx # page principale (l'écran)
28
+ src/pages/billing/components/ # sous-composants de l'écran (voir plus bas)
29
+ src/models/Billing/AccountingExport.ts # modèles class-transformer
30
+ src/services/Billing/AccountingExportService.ts # service + hooks React Query
31
+ ```
32
+
33
+ Créer les dossiers `src/models/Billing` et `src/services/Billing` s'ils n'existent pas.
34
+ Router : ajouter la route de la page dans `src/App.tsx` (suivre exactement le pattern des
35
+ autres pages `billing/*` déjà importées là — ex. `BankTransactions`, `Stocks`).
36
+
37
+ ---
38
+
39
+ ## Contraintes projet (rappel — ne pas dévier)
40
+
41
+ - **Composants fonctionnels uniquement**, hooks. Pas de class component React.
42
+ - **Data fetching = React Query v5** (`useQuery` / `useInfiniteQuery` / `useMutation`).
43
+ Jamais `useEffect` + fetch manuel.
44
+ - **Modèles = class-transformer** (`@Expose()`, `@Type()`, `plainToInstance(..., { excludeExtraneousValues: true })`),
45
+ méthodes statiques `JsonToObject` / `JsonToObjects`. Reproduire à l'identique le pattern
46
+ de `src/models/SupplierOrder.ts`.
47
+ - **Appels API via `request`** importé de `@movalib/movalib-commons` (avec `APIMethod`,
48
+ `APIResponse`), `API_BASE_URL` depuis `src/helpers/Constants`. Suivre `SupplierOrderService`
49
+ / `DataBoxService` comme référence.
50
+ - **Styles = MUI `sx` prop ou `styled()`**. Aucun CSS inline, aucun fichier `.css`, aucune
51
+ variable CSS de la maquette.
52
+ - **Dates = date-fns v2** (`format`, locale `fr`). Pas de moment (sauf code existant).
53
+ - **Feedback utilisateur = `useSnackbar` / `showSnackbar(message, severity)`** (pattern existant).
54
+ - **Chips de statut** : suivre le pattern `QuoteStatusChip` (variantes palette `light` / `dark`).
55
+ - Pas de `any` non justifié, pas de `console.log`, pas de props drilling > 2 niveaux.
56
+ - Ne pas réécrire de fichiers hors périmètre ; proposer les diffs pour `App.tsx`.
57
+
58
+ ---
59
+
60
+ ## API backend
61
+
62
+ Base : `${API_BASE_URL}/accounting/garage/{garageId}/...`
63
+ `garageId` provient du garage courant (même mécanisme que les autres pages du module —
64
+ récupérer via le contexte / `useFetchAdministratedGarages` comme ailleurs, ne pas hardcoder `20`).
65
+
66
+ | Action | Méthode & URL |
67
+ |---|---|
68
+ | Liste paginée des exports | `GET /exports?page={p}&size={s}` |
69
+ | Écritures en attente (WIP) | `GET /exports/pending` *(à créer côté API — voir note)* |
70
+ | Générer un export | `POST /exports?until={yyyy-MM-dd}` |
71
+ | État d'un export | `GET /exports/{exportId}` |
72
+ | Annuler le dernier export non téléchargé | `DELETE /exports/{exportId}` |
73
+ | Déclencher un téléchargement | `POST /exports/{exportId}/download` |
74
+ | État d'un téléchargement | `GET /sinao-downloads/{downloadId}` |
75
+
76
+ > **Note pagination / pending :** aujourd'hui `GET /exports` **et** `GET /exports/pending`
77
+ > ne sont pas encore livrés côté back sous cette forme. Deux formes de réponse ont été
78
+ > observées dans les échantillons (voir plus bas) :
79
+ > 1. une réponse `Page<Export>` Spring « nue » (`{ content, pageable, totalPages, ... }`) ;
80
+ > 2. une réponse enveloppée `{ exports: Page<Export>, pendingEntriesCount: number }`.
81
+ >
82
+ > **Implémenter le service de façon défensive** : parser `pendingEntriesCount` depuis la
83
+ > réponse liste **si présente**, sinon consommer `GET /exports/pending`. Isoler ce choix
84
+ > dans le service pour qu'un seul endroit change quand l'API sera figée. Documenter par un
85
+ > commentaire l'hypothèse retenue.
86
+
87
+ ### Formes de réponse (échantillons réels)
88
+
89
+ **Génération — `POST /exports?until=...`** (statut initial `generating`) :
90
+ ```json
91
+ { "exportId": 250, "periodStart": "2026-05-01", "periodEnd": "2026-05-16", "status": "generating" }
92
+ ```
93
+
94
+ **État d'un export — `GET /exports/{id}`** :
95
+ ```json
96
+ {
97
+ "id": 250,
98
+ "periodStart": "2026-05-01",
99
+ "periodEnd": "2026-05-16",
100
+ "status": "generated",
101
+ "sinaoEntriesCount": 23,
102
+ "sinaoZipUrl": null,
103
+ "downloadedAt": null,
104
+ "transmissions": [],
105
+ "canTransmit": ["pennylane"],
106
+ "createdAt": "2026-09-02T16:20:30.741872Z"
107
+ }
108
+ ```
109
+
110
+ **Liste — `GET /exports` (forme 1, Page nue)** :
111
+ ```json
112
+ {
113
+ "content": [ { "id": 250, "periodStart": "...", "periodEnd": "...", "status": "generated",
114
+ "sinaoEntriesCount": 23, "sinaoZipUrl": "https://...", "downloadedAt": "2026-09-02T16:21:54Z",
115
+ "transmissions": [], "canTransmit": ["pennylane"], "createdAt": "..." } ],
116
+ "totalElements": 1, "totalPages": 1, "number": 0, "size": 12, "first": true, "last": true, "empty": false
117
+ }
118
+ ```
119
+
120
+ **Liste — `GET /exports` (forme 2, enveloppée + WIP)** :
121
+ ```json
122
+ { "exports": { "content": [ ...Export ], "totalPages": 1, "totalElements": 2, "number": 0, "size": 12, ... },
123
+ "pendingEntriesCount": 20 }
124
+ ```
125
+
126
+ **État d'un téléchargement — `GET /sinao-downloads/{id}`** (⚠️ snake_case ici) :
127
+ ```json
128
+ {
129
+ "id": 80,
130
+ "status": "finished",
131
+ "entries_count": 9,
132
+ "file_url": "https://sinao-prod-storage.s3.eu-west-3.amazonaws.com/.../80.zip?...",
133
+ "period_start": "2026-05-14T22:00:00.000000Z",
134
+ "period_end": "2026-05-26T22:00:00.000000Z",
135
+ "error_message": null,
136
+ "info_message": null
137
+ }
138
+ ```
139
+
140
+ > ⚠️ Incohérence de casing entre endpoints : `/exports/*` renvoie du **camelCase**,
141
+ > `/sinao-downloads/*` du **snake_case**. Gérer via les `@Expose({ name: ... })` dans deux
142
+ > classes distinctes.
143
+
144
+ ### Statuts (`Export.status`)
145
+
146
+ Valeurs observées : `generating`, `generated`. Prévoir aussi un statut d'échec
147
+ (`failed` / `error` — à confirmer côté back ; mapper de façon tolérante et logguer un warn
148
+ si valeur inconnue plutôt que crash). Modéliser en `enum` :
149
+
150
+ ```ts
151
+ export enum AccountingExportStatus {
152
+ GENERATING = "generating",
153
+ GENERATED = "generated",
154
+ FAILED = "failed", // à confirmer avec le back
155
+ }
156
+ ```
157
+
158
+ État de téléchargement (`SinaoDownload.status`) : `pending` / `processing` / `finished` /
159
+ `failed` (observé : `finished`). Même prudence.
160
+
161
+ ---
162
+
163
+ ## Modèles à créer — `src/models/Billing/AccountingExport.ts`
164
+
165
+ Suivre **exactement** le pattern class-transformer de `SupplierOrder`.
166
+
167
+ - `AccountingExport`
168
+ - `@Expose() id!: number`
169
+ - `@Expose() periodStart!: string` / `@Expose() periodEnd!: string`
170
+ - `@Expose() status!: AccountingExportStatus`
171
+ - `@Expose() sinaoEntriesCount?: number | null`
172
+ - `@Expose() sinaoZipUrl?: string | null`
173
+ - `@Expose() downloadedAt?: string | null`
174
+ - `@Expose() createdAt?: string`
175
+ - `@Expose() transmissions?: unknown[]` (typer plus finement si le back documente)
176
+ - `@Expose() canTransmit?: string[]`
177
+ - statiques `JsonToObject` / `JsonToObjects`
178
+ - helpers dérivés (getters ou fonctions pures exportées), utilisés par l'UI :
179
+ - `isDownloaded` = `downloadedAt != null`
180
+ - `isGenerating` = `status === GENERATING`
181
+ - `isFailed` = `status === FAILED`
182
+ - `isAvailable` = `status === GENERATED && downloadedAt == null`
183
+
184
+ - `SinaoDownload` (mapping snake_case)
185
+ - `@Expose() id!: number`
186
+ - `@Expose() status!: SinaoDownloadStatus`
187
+ - `@Expose({ name: "entries_count" }) entriesCount?: number`
188
+ - `@Expose({ name: "file_url" }) fileUrl?: string | null`
189
+ - `@Expose({ name: "period_start" }) periodStart?: string`
190
+ - `@Expose({ name: "period_end" }) periodEnd?: string`
191
+ - `@Expose({ name: "error_message" }) errorMessage?: string | null`
192
+ - `@Expose({ name: "info_message" }) infoMessage?: string | null`
193
+ - statiques `JsonToObject`
194
+
195
+ - `PendingEntries` (si `GET /exports/pending` renvoie un objet) : `@Expose() count!: number`
196
+ (adapter au vrai payload ; à défaut typer `{ count: number }`).
197
+
198
+ - Type d'aide pour la liste paginée (générique Spring) — soit réutiliser un type `Page<T>`
199
+ déjà présent dans le projet **s'il existe**, sinon en définir un local :
200
+ ```ts
201
+ export type Page<T> = {
202
+ content: T[];
203
+ totalPages: number;
204
+ totalElements: number;
205
+ number: number; // index de page courant
206
+ size: number;
207
+ first: boolean;
208
+ last: boolean;
209
+ empty: boolean;
210
+ };
211
+ ```
212
+
213
+ ---
214
+
215
+ ## Service à créer — `src/services/Billing/AccountingExportService.ts`
216
+
217
+ Clé de cache : `export const ACCOUNTING_EXPORT = "ACCOUNTING_EXPORT";`
218
+
219
+ Hooks / fonctions à exposer (adapter la signature au pattern maison `useFetch...`) :
220
+
221
+ 1. **`useFetchPendingEntriesCount(garageId)`** → `useQuery`
222
+ - `GET /exports/pending` (ou lire `pendingEntriesCount` de la liste selon la note API).
223
+ - Renvoie un `number`. `queryKey: [ACCOUNTING_EXPORT, "pending", garageId]`.
224
+
225
+ 2. **`useFetchExports(garageId, { page, size })`** → `useQuery`
226
+ - `GET /exports?page=&size=`. `size` par défaut = **12** (cf. échantillons).
227
+ - Renvoie un `Page<AccountingExport>`. Gérer les deux formes de payload (nue / enveloppée).
228
+ - `queryKey: [ACCOUNTING_EXPORT, "list", garageId, page, size]`.
229
+
230
+ 3. **`useFetchExportsInfinite(garageId, { size })`** → `useInfiniteQuery`
231
+ - Pour l'historique « Voir tout ». `getNextPageParam` basé sur `totalPages` (cf. pattern
232
+ `useFetchQuoteByState` déjà utilisé dans le projet). Aplatir via `data.pages.flatMap`.
233
+ - À utiliser pour la liste « Déjà téléchargés » quand on déplie l'historique complet.
234
+
235
+ 4. **`useFetchExport(garageId, exportId, options?)`** → `useQuery`
236
+ - `GET /exports/{id}`. Sert au **polling** : activer `refetchInterval` tant que
237
+ `status === generating` (ex. 2000 ms), le couper sinon. `enabled` sur `!!exportId`.
238
+
239
+ 5. **`useGenerateExport(garageId)`** → `useMutation`
240
+ - `POST /exports?until={yyyy-MM-dd}`. Par défaut `until = today` (format date-fns
241
+ `yyyy-MM-dd`). Sur succès : invalider `pending` + `list`, retourner l'`AccountingExport`
242
+ initial (`generating`) pour amorcer le polling.
243
+
244
+ 6. **`useCancelExport(garageId)`** → `useMutation`
245
+ - `DELETE /exports/{id}`. Sur succès : invalider `pending` + `list`.
246
+ - Rappel métier : seul le **dernier** export **non téléchargé** est annulable — l'UI ne
247
+ doit proposer « Annuler » que dans ce cas, mais gérer aussi un refus back (409/403) via snackbar.
248
+
249
+ 7. **`useDownloadExport(garageId)`** → `useMutation`
250
+ - `POST /exports/{id}/download` → renvoie (ou permet de récupérer) un `downloadId`
251
+ (`sinao-download`). Puis suivre l'état.
252
+
253
+ 8. **`useFetchSinaoDownload(garageId, downloadId, options?)`** → `useQuery`
254
+ - `GET /sinao-downloads/{id}`, polling tant que `status` ∈ {pending, processing}.
255
+ - Quand `finished` + `file_url` : déclencher le téléchargement effectif du fichier
256
+ (réutiliser le helper de download authentifié déjà présent dans le projet — même
257
+ mécanisme que le download PDF factures : `readCookie("movalibProToken")`, blob, `<a>`
258
+ programmatique. **Ne pas réinventer** : chercher le helper existant et le réutiliser).
259
+
260
+ 9. **`invalidateAccountingExports(queryClient)`** → `invalidateQueries({ queryKey: [ACCOUNTING_EXPORT], exact: false })`.
261
+
262
+ Toutes les fonctions passent par `request({ url, method, body? })`, puis
263
+ `AccountingExport.JsonToObject(res)` / `.JsonToObjects(res.content)` selon le cas.
264
+ Gérer les erreurs avec `.catch` → `throw new Error(...)` remonté aux mutations pour le snackbar.
265
+
266
+ ---
267
+
268
+ ## Écran — `src/pages/billing/AccountingExports.tsx`
269
+
270
+ **Ordre des blocs à respecter (issu de la maquette) — du haut vers le bas :**
271
+
272
+ 1. **En-tête** : titre « Écritures comptables » + nom du garage (dans le style d'en-tête
273
+ des autres pages du module, pas la mise en forme de la maquette).
274
+
275
+ 2. **Bloc générateur** (carte MUI, style thème) :
276
+ - Grand **compteur** = nombre d'écritures en attente (`pending`) + libellé
277
+ « pièces prêtes à exporter » (accord singulier/pluriel).
278
+ - Ligne d'info secondaire : « Saisies depuis votre dernière génération, le {date du dernier export} »
279
+ (formatée date-fns/fr). Si aucun export précédent, adapter le texte.
280
+ - **Bouton principal « Générer l'export FEC »** (couleur primaire du thème) :
281
+ - actif si `pending > 0` et pas de génération en cours ;
282
+ - **désactivé** avec libellé « Aucune écriture à exporter » si `pending === 0` ;
283
+ - **désactivé** avec libellé « Génération en cours… » pendant un `generating`.
284
+ - **Zone de progression** (visible seulement pendant `generating`) : un indicateur de
285
+ progression MUI (`LinearProgress` — indéterminé, car l'API n'expose pas de %) + texte
286
+ d'état. À la fin : message « Export prêt — à télécharger ci-dessous » (style thème,
287
+ pas la pastille jaune de la maquette).
288
+ - **État « génération déjà lancée au chargement »** : si, au montage, un export est déjà
289
+ `generating` (présent en tête de liste), afficher directement l'état en cours + bouton
290
+ désactivé, et démarrer le polling.
291
+
292
+ 3. **Bloc « À télécharger »** (exports `isAvailable` = generated & non téléchargés) :
293
+ - En-tête de section « {n} fichier(s) à télécharger ».
294
+ - Une ligne par export : date (`createdAt`), détail « {sinaoEntriesCount} écritures »,
295
+ bouton **Télécharger**.
296
+ - Sur **le tout premier** (le plus récent) et **seulement s'il est le dernier généré non
297
+ téléchargé** : bouton **Annuler** (avec confirmation — voir plus bas).
298
+ - Ligne nouvellement générée : léger highlight transitoire (via `sx`/thème), optionnel.
299
+ - **État « échec »** : si un export est `failed`, l'afficher en ligne d'erreur (couleur
300
+ d'erreur du thème) avec message « La génération a échoué. Aucune écriture n'a été
301
+ consommée, vous pouvez relancer. » + bouton **Relancer** (= re-générer).
302
+
303
+ 4. **État « Tout récupéré »** : si aucun export « à télécharger » → petit bloc centré
304
+ « Tous vos fichiers ont été récupérés. » (icône check du thème).
305
+
306
+ 5. **Section « Déjà téléchargés »** (exports `isDownloaded`) :
307
+ - Liste : icône check, résumé « {date} · {n} écritures », mention « récupéré le {downloadedAt} »,
308
+ bouton discret **Télécharger** (re-download).
309
+ - **Pagination** : afficher d'abord la première page (size 12), puis bouton
310
+ **« Voir tout l'historique »** qui bascule sur `useFetchExportsInfinite` (ou incrémente
311
+ la page) — l'historique peut être volumineux, ne jamais tout charger d'un coup.
312
+
313
+ **Cas limites à couvrir explicitement (critères d'acceptation) :**
314
+ - `pending === 0` → compteur 0 + bouton désactivé « Aucune écriture à exporter ».
315
+ - Génération en cours → progression + bouton désactivé + polling.
316
+ - Échec de génération → ligne d'erreur + relance possible.
317
+ - Annulation impossible (un export plus récent existe) → **ne pas** afficher « Annuler » sur
318
+ les exports concernés ; si le back refuse une annulation, snackbar explicite.
319
+ - Historique volumineux → pagination / infinite scroll.
320
+ - Aucun doublon entre exports successifs : garanti côté back (incrémental) ; l'UI se contente
321
+ de ne jamais relancer deux générations en parallèle (désactiver le bouton pendant le WIP).
322
+
323
+ ---
324
+
325
+ ## Sous-composants suggérés (`src/pages/billing/components/`)
326
+
327
+ Découper pour rester lisible (chaque fichier ≈ une responsabilité) :
328
+
329
+ - `ExportGenerator.tsx` — carte compteur + bouton générer + progression.
330
+ - `ExportAvailableList.tsx` — liste « à télécharger » (+ gestion Annuler/Relancer).
331
+ - `ExportDownloadedList.tsx` — liste « déjà téléchargés » + pagination / voir tout.
332
+ - `ExportRow.tsx` — ligne d'export réutilisable (variantes : available / downloaded / error).
333
+ - `CancelExportConfirm.tsx` — confirmation d'annulation (dialog MUI ou confirm inline selon
334
+ le pattern de confirmation déjà utilisé dans movalib-pro — réutiliser l'existant).
335
+ - `AccountingExportStatusChip.tsx` — chip de statut façon `QuoteStatusChip`.
336
+
337
+ Le téléchargement effectif du ZIP réutilise le helper de download authentifié existant
338
+ (cf. download PDF factures). Chercher et réutiliser, ne pas dupliquer.
339
+
340
+ ---
341
+
342
+ ## Confirmation d'annulation
343
+
344
+ Au clic sur « Annuler » : demander confirmation avec un message du type
345
+ « Annuler l'export du {date} ? Ses {n} écritures repartiront dans le prochain export. »
346
+ → boutons « Conserver » / « Annuler l'export ». Sur confirmation : `useCancelExport`,
347
+ puis invalider `pending` + `list` (le compteur WIP doit augmenter du nombre d'écritures
348
+ restituées). Utiliser le composant/dialog de confirmation déjà en place dans le projet.
349
+
350
+ ---
351
+
352
+ ## Correspondance maquette → DA movalib-pro
353
+
354
+ | Élément maquette | À remplacer par |
355
+ |---|---|
356
+ | Police Outfit, variables `--vert/--jaune/--rose/--noir` | Thème MUI (`theme.palette.*`), typo du thème |
357
+ | `.bouton--principal` vert | `Button variant="contained"` couleur primaire du thème |
358
+ | `.bouton--telecharger` noir | `Button variant="contained"` selon thème (secondaire/neutre) |
359
+ | `.bouton--annuler` / `--discret` | `Button variant="outlined"` / `variant="text"` |
360
+ | Bande jaune « à télécharger » | En-tête de section MUI (couleur d'accent du thème) |
361
+ | Pastille jaune « Export prêt » | `Alert`/texte info du thème |
362
+ | `.piste` + `.jauge` custom | `LinearProgress` MUI (indéterminé) |
363
+ | Puce rose d'erreur / `.ligne--erreur` | Couleur `error` du thème + chip/texte d'erreur |
364
+ | Icônes SVG inline | Icônes MUI (`@mui/icons-material`) équivalentes |
365
+ | Highlights `--neuve` / `--recuperee` | `sx` avec transition légère du thème (optionnel) |
366
+
367
+ Le **JS de la maquette est illustratif** (état simulé) : ne pas le porter. La logique réelle
368
+ vient des hooks React Query ci-dessus.
369
+
370
+ ---
371
+
372
+ ## Definition of Done
373
+
374
+ - [ ] Route ajoutée dans `App.tsx` (pattern des pages `billing/*`), accès depuis Facturation.
375
+ - [ ] Modèles `AccountingExport` / `SinaoDownload` en class-transformer, casing géré.
376
+ - [ ] Service complet (pending, liste paginée + infinite, état export, générer, annuler,
377
+ download + suivi sinao-download, invalidation).
378
+ - [ ] Écran conforme à l'ordre des blocs, tous les états couverts (pending 0, generating,
379
+ failed, available, downloaded, annulation impossible, historique paginé).
380
+ - [ ] DA respectée (MUI + thème, aucune valeur en dur de la maquette).
381
+ - [ ] Polling génération + polling download, coupés dès l'état terminal.
382
+ - [ ] Aucune génération en double possible (bouton verrouillé pendant le WIP).
383
+ - [ ] `tsc` sans erreur, pas de `any` non justifié, pas de `console.log`.
384
+ - [ ] `garageId` dynamique (pas de `20` en dur).
385
+
386
+ ## Questions ouvertes à confirmer côté back (à signaler, ne pas bloquer)
387
+
388
+ - Forme définitive de `GET /exports` (nue vs enveloppée + `pendingEntriesCount`).
389
+ - Payload exact de `GET /exports/pending`.
390
+ - Valeur exacte du statut d'échec (`failed` ? `error` ?).
391
+ - Réponse de `POST /exports/{id}/download` : renvoie-t-elle directement le `downloadId` /
392
+ l'URL, ou faut-il enchaîner sur `GET /sinao-downloads/{id}` avec un id connu autrement ?
@@ -0,0 +1,78 @@
1
+ Tous préfixés /api/billing/{garageId}/packages
2
+
3
+ ┌─────────┬──────────────────────────────────────────────┬─────────────────────────────────────┐
4
+ │ Méthode │ URL │ Description │
5
+ ├─────────┼──────────────────────────────────────────────┼─────────────────────────────────────┤
6
+ │ GET │ /{garageId}/packages?search=&page=0&limit=20 │ Liste paginée des forfaits │
7
+ ├─────────┼──────────────────────────────────────────────┼─────────────────────────────────────┤
8
+ │ GET │ /{garageId}/packages/{packageId} │ Détail d'un forfait avec composants │
9
+ ├─────────┼──────────────────────────────────────────────┼─────────────────────────────────────┤
10
+ │ POST │ /{garageId}/packages │ Création d'un forfait │
11
+ ├─────────┼──────────────────────────────────────────────┼─────────────────────────────────────┤
12
+ │ PATCH │ /{garageId}/packages/{packageId} │ Modification d'un forfait │
13
+ ├─────────┼──────────────────────────────────────────────┼─────────────────────────────────────┤
14
+ │ DELETE │ /{garageId}/packages/{packageId} │ Suppression d'un forfait │
15
+ └─────────┴──────────────────────────────────────────────┴─────────────────────────────────────┘
16
+
17
+ Exemple body POST/PATCH :
18
+ {
19
+ "reference": "FORFAIT-VIDANGE-01",
20
+ "label": "Forfait vidange complète",
21
+ "amountCents": 8000,
22
+ "amountInclVatCents": 9600,
23
+ "vatRate": 20.00,
24
+ "accountId": 42,
25
+ "components": [
26
+ {
27
+ "catalogId": 101,
28
+ "reference": "HUI-MOT-5W30",
29
+ "label": "Huile moteur 5W30 - 5L",
30
+ "quantity": 1,
31
+ "amountCents": 3500,
32
+ "amountInclVatCents": 4200,
33
+ "vatRate": 20.00,
34
+ "index": 0
35
+ },
36
+ {
37
+ "catalogId": 102,
38
+ "reference": "FIL-HUI-01",
39
+ "label": "Filtre à huile",
40
+ "quantity": 1,
41
+ "amountCents": 1000,
42
+ "amountInclVatCents": 1200,
43
+ "vatRate": 20.00,
44
+ "index": 1
45
+ }
46
+ ],
47
+ "labor": {
48
+ "serviceId": 55,
49
+ "reference": "MO-VIDANGE",
50
+ "label": "Main d'œuvre vidange",
51
+ "quantity": 1,
52
+ "amountCents": 3500,
53
+ "amountInclVatCents": 4200,
54
+ "vatRate": 20.00
55
+ }
56
+ }
57
+
58
+ Tous les montants sont en centimes (8000 = 80,00 €). La somme Σ composants TTC + labor TTC doit être égale à amountInclVatCents — le back valide cette cohérence.
59
+
60
+ Modifications sur les appels existants updateInvoice / updateQuote
61
+
62
+ Quand un forfait est ajouté sur un devis ou une facture, il faut passer les IDs des forfaits concernés en query params sur les endpoints POST /{garageId}/invoices/{invoiceId} et POST
63
+ /{garageId}/quotes/{quoteId} :
64
+
65
+ POST /api/billing/12/invoices/456?packageIds[0]=7&packageIds[1]=12
66
+
67
+ Ces IDs servent à alimenter la table analytique document_packages. À chaque appel, le back repart de zéro et recrée les associations — pas d'état à maintenir côté front, envoyer simplement la liste
68
+ complète des forfaits présents sur le document à ce moment-là. Si le document n'a plus de forfait, ne pas envoyer le paramètre (ou envoyer une liste vide).
69
+
70
+ Nouvelles propriétés sur les lignes du document
71
+
72
+ Quand le front crée une ligne de type commentaire correspondant à un composant de forfait, il doit stocker et renvoyer l'UUID du PackageComponent associé. Ce champ est retourné dans la réponse GET du
73
+ forfait (components[].id).
74
+
75
+ Ce packageComponentId est ce qui permet au back de déclencher la sortie de stock correcte au moment de la finalisation de la facture, en ignorant la quantité Sinao et en utilisant celle définie dans
76
+ le forfait.
77
+
78
+ ⚠️ Si l'utilisateur supprime une ligne COMPONENT du document avant de finaliser, ne pas envoyer le packageComponentId → pas de sortie de stock pour ce composant (comportement attendu).