Compare commits
5 Commits
v1.8.0
...
8f5f25b3e7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f5f25b3e7 | ||
|
|
c06c852493 | ||
|
|
41f5319b67 | ||
|
|
c7fd8328d6 | ||
|
|
55e2a4fafe |
111
README.md
111
README.md
@@ -1,75 +1,78 @@
|
||||
# Nuxt Minimal Starter
|
||||
# Inventory Frontend
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
Frontend de l'application de gestion d'inventaire industriel Malio.
|
||||
|
||||
## Setup
|
||||
## Stack
|
||||
|
||||
Make sure to install dependencies:
|
||||
| Tech | Version |
|
||||
|------|---------|
|
||||
| Nuxt | 4 (SPA, SSR off) |
|
||||
| Vue | 3 Composition API |
|
||||
| TypeScript | 5.7 |
|
||||
| CSS | TailwindCSS 4 + DaisyUI 5 |
|
||||
| Icônes | unplugin-icons (Lucide) |
|
||||
| Tests | Vitest |
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Node.js >= 20
|
||||
- npm
|
||||
- Backend Symfony démarré (API sur `http://localhost:8081/api`)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
Le serveur de dev est accessible sur `http://localhost:3001`.
|
||||
|
||||
Build the application for production:
|
||||
## Commandes
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
npm run dev # Serveur de développement
|
||||
npm run build # Build production
|
||||
npm run lint:fix # Correction ESLint
|
||||
npx nuxi typecheck # Vérification TypeScript (0 erreurs attendu)
|
||||
npm run test # Tests unitaires Vitest
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
## Structure
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
app/
|
||||
├── pages/ # Pages Nuxt (file-based routing)
|
||||
├── components/ # Composants Vue (auto-imported)
|
||||
├── composables/ # Composables Vue (auto-imported)
|
||||
├── shared/ # Types, utils, validation
|
||||
│ └── utils/ # Utilitaires partagés
|
||||
├── middleware/ # Auth middleware global
|
||||
├── services/ # Service layer (wrappers useApi)
|
||||
└── utils/ # Utilitaires Nuxt
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
## Conventions
|
||||
|
||||
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
|
||||
- **Communication** : Props + Events uniquement (pas de provide/inject)
|
||||
- **API** : `useApi.ts` avec `credentials: 'include'` (auth session/cookie)
|
||||
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
|
||||
- **Auto-imports** : Nuxt auto-importe `components/` et `composables/`
|
||||
|
||||
## Auth
|
||||
|
||||
Authentification par session (cookies), pas de JWT. Le middleware global `profile.global.ts` protège les routes.
|
||||
|
||||
## Submodule
|
||||
|
||||
Ce repo est un submodule git du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory). Workflow :
|
||||
|
||||
1. Commiter ici d'abord
|
||||
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Pousser les deux repos
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideX
|
||||
<IconLucideCircleX
|
||||
v-else-if="toast.type === 'error'"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
@@ -64,6 +64,7 @@
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
import IconLucideInfo from '~icons/lucide/info'
|
||||
|
||||
|
||||
@@ -24,9 +24,10 @@
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="rounded-md px-2 py-1 transition-colors"
|
||||
class="rounded-md px-2 py-1 transition-colors flex items-center gap-2"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
@@ -46,7 +47,10 @@
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
>
|
||||
<span>{{ group.label }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ group.label }}
|
||||
</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
||||
@@ -100,9 +104,10 @@
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="transition-colors px-3 py-2 rounded-md"
|
||||
class="transition-colors px-3 py-2 rounded-md flex items-center gap-1.5"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
@@ -119,13 +124,14 @@
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded-md px-3 py-2 transition-colors"
|
||||
class="inline-flex items-center gap-1.5 rounded-md px-3 py-2 transition-colors"
|
||||
:class="groupClass(group)"
|
||||
:aria-expanded="openDropdown === group.id + '-desktop'"
|
||||
@click="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
>
|
||||
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ group.label }}
|
||||
<IconLucideChevronRight
|
||||
class="h-4 w-4 transition-transform"
|
||||
@@ -233,7 +239,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
@@ -243,6 +249,13 @@ import IconLucideMenu from '~icons/lucide/menu'
|
||||
import IconLucideSettings from '~icons/lucide/settings'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||
import IconLucideCpu from '~icons/lucide/cpu'
|
||||
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import IconLucideLink from '~icons/lucide/link'
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
|
||||
defineEmits<{
|
||||
@@ -253,25 +266,38 @@ defineEmits<{
|
||||
interface NavLink {
|
||||
to: string
|
||||
label: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
activePaths: string[]
|
||||
children: NavLink[]
|
||||
}
|
||||
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble' },
|
||||
{ to: '/machines', label: 'Parc Machines' },
|
||||
{ to: '/machine-skeleton', label: 'Squelettes de machine' },
|
||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||
{ to: '/machine-skeleton', label: 'Squelettes', icon: IconLucideClipboardList },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composants',
|
||||
icon: IconLucideCpu,
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pieces',
|
||||
label: 'Pièces',
|
||||
icon: IconLucidePuzzle,
|
||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||
children: [
|
||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||
@@ -281,24 +307,17 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Produits',
|
||||
icon: IconLucidePackage,
|
||||
activePaths: ['/product-category', '/product-catalog'],
|
||||
children: [
|
||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composant',
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
icon: IconLucideLink,
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
type ModelTypeListResponse,
|
||||
} from "~/services/modelTypes";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
import { humanizeError } from "~/shared/utils/errorMessages";
|
||||
import { invalidateEntityTypeCache } from "~/composables/useEntityTypes";
|
||||
|
||||
const DEFAULT_DESCRIPTION =
|
||||
@@ -183,7 +184,8 @@ useHead(() => ({
|
||||
title: headingText.value,
|
||||
}));
|
||||
|
||||
const extractErrorMessage = (error: unknown) => {
|
||||
const extractErrorMessage = (error: unknown): string => {
|
||||
let raw: string | null = null;
|
||||
if (error && typeof error === "object") {
|
||||
const maybeFetchError = error as {
|
||||
data?: Record<string, unknown>;
|
||||
@@ -192,21 +194,16 @@ const extractErrorMessage = (error: unknown) => {
|
||||
};
|
||||
if (maybeFetchError.data) {
|
||||
const data = maybeFetchError.data;
|
||||
if (typeof data.message === "string") {
|
||||
return data.message;
|
||||
}
|
||||
if (Array.isArray(data.message) && data.message.length > 0) {
|
||||
return data.message[0];
|
||||
}
|
||||
}
|
||||
if (typeof maybeFetchError.statusMessage === "string") {
|
||||
return maybeFetchError.statusMessage;
|
||||
}
|
||||
if (typeof maybeFetchError.message === "string") {
|
||||
return maybeFetchError.message;
|
||||
if (typeof data['hydra:description'] === "string") raw = data['hydra:description'];
|
||||
else if (typeof data.detail === "string") raw = data.detail;
|
||||
else if (typeof data.message === "string") raw = data.message;
|
||||
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0];
|
||||
else if (typeof data.error === "string") raw = data.error;
|
||||
}
|
||||
if (!raw && typeof maybeFetchError.statusMessage === "string") raw = maybeFetchError.statusMessage;
|
||||
if (!raw && typeof maybeFetchError.message === "string") raw = maybeFetchError.message;
|
||||
}
|
||||
return "Une erreur est survenue lors de la communication avec le serveur.";
|
||||
return humanizeError(raw);
|
||||
};
|
||||
|
||||
const refresh = async ({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useToast } from './useToast'
|
||||
import { humanizeError, extractApiErrorMessage } from '~/shared/utils/errorMessages'
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
@@ -59,23 +60,26 @@ export function useApi() {
|
||||
} else {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let errorData: Record<string, unknown> = {}
|
||||
if (contentType.includes('application/json')) {
|
||||
if (contentType.includes('json')) {
|
||||
errorData = await response.json().catch(() => ({}))
|
||||
} else {
|
||||
const text = await response.text().catch(() => '')
|
||||
errorData = text ? { message: text } : {}
|
||||
}
|
||||
const errorMessage = response.status === 403
|
||||
const rawMessage = response.status === 403
|
||||
? 'Permissions insuffisantes pour cette action.'
|
||||
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
||||
: extractApiErrorMessage(errorData) || `Erreur ${response.status}: ${response.statusText}`
|
||||
const errorMessage = humanizeError(rawMessage)
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage, status: response.status }
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
const err = error as Error & { name?: string }
|
||||
const errorMessage = err.name === 'AbortError' ? 'Timeout de la requête' : err.message || 'Erreur réseau'
|
||||
showError(`Erreur réseau: ${errorMessage}`)
|
||||
const errorMessage = err.name === 'AbortError'
|
||||
? 'La requête a pris trop de temps. Veuillez réessayer.'
|
||||
: 'Impossible de contacter le serveur. Vérifiez votre connexion.'
|
||||
showError(errorMessage)
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import {
|
||||
listModelTypes,
|
||||
createModelType,
|
||||
@@ -102,8 +103,8 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true, data: state.types.value }
|
||||
} catch (error) {
|
||||
const err = error as Error & { message?: string }
|
||||
const message = err?.message || 'Erreur inconnue'
|
||||
showError(`Impossible de charger les types de ${label}: ${message}`)
|
||||
const message = humanizeError(err?.message)
|
||||
showError(`Impossible de charger les types de ${label}.`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
@@ -127,8 +128,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la création du type de ${label}: ${message}`)
|
||||
const raw = err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de créer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
@@ -152,8 +154,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true, data: normalized }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la mise à jour du type de ${label}: ${message}`)
|
||||
const raw = err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de mettre à jour le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
@@ -169,8 +172,9 @@ export function useEntityTypes(config: EntityTypeConfig) {
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const err = error as Error & { data?: { message?: string }; message?: string }
|
||||
const message = err?.data?.message || err?.message || 'Erreur inconnue'
|
||||
showError(`Erreur lors de la suppression du type de ${label}: ${message}`)
|
||||
const raw = err?.data?.message || err?.message
|
||||
const message = humanizeError(raw)
|
||||
showError(`Impossible de supprimer le type de ${label} : ${message}`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
state.loading.value = false
|
||||
|
||||
@@ -15,6 +15,7 @@ import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useMachineCreateSelections } from '~/composables/useMachineCreateSelections'
|
||||
import {
|
||||
useMachineCreatePreview,
|
||||
@@ -365,10 +366,10 @@ export function useMachineCreatePage() {
|
||||
clearRequirementSelections()
|
||||
await navigateTo('/machines')
|
||||
} else if (result.error) {
|
||||
toast.showError(`Impossible de créer la machine: ${result.error}`)
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(`Erreur lors de la création: ${error.message}`)
|
||||
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from './useToast'
|
||||
import { useApi } from './useApi'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { buildConstructeurRequestPayload, uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs, type Constructeur } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
@@ -168,9 +169,9 @@ export function useProducts() {
|
||||
return result as ProductListResult
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement des produits:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(`Impossible de charger les produits: ${message}`)
|
||||
showError(`Impossible de charger les produits.`)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -197,9 +198,9 @@ export function useProducts() {
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la création du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(message)
|
||||
showError('Impossible de créer le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -223,9 +224,9 @@ export function useProducts() {
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la mise à jour du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(message)
|
||||
showError('Impossible de mettre à jour le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -248,9 +249,9 @@ export function useProducts() {
|
||||
return { success: false, error: result.error }
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la suppression du produit:', err)
|
||||
const message = (err as Error)?.message ?? 'Erreur inconnue'
|
||||
const message = humanizeError((err as Error)?.message)
|
||||
error.value = message
|
||||
showError(message)
|
||||
showError('Impossible de supprimer le produit.')
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { navigateTo, useRoute } from '#imports'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
@@ -274,10 +275,10 @@ export function useSiteManagement() {
|
||||
if (result.success) {
|
||||
showSuccess(`Site "${site.name}" supprimé avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
showError(`Impossible de supprimer le site : ${humanizeError(result.error)}`)
|
||||
}
|
||||
} catch (error: any) {
|
||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
||||
showError(`Impossible de supprimer le site : ${humanizeError(error.message)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,19 @@ const toasts = ref<Toast[]>([])
|
||||
const MAX_TOASTS = 3
|
||||
let nextId = 1
|
||||
|
||||
// Anti-doublon : ignore un toast identique affiché dans les 2 dernières secondes
|
||||
const recentMessages = new Map<string, number>()
|
||||
const DEDUP_WINDOW = 2000
|
||||
|
||||
export function useToast() {
|
||||
const showToast = (message: string, type: ToastType = 'info', duration = 3500): number => {
|
||||
const dedupKey = `${type}::${message}`
|
||||
const lastShown = recentMessages.get(dedupKey)
|
||||
if (lastShown && Date.now() - lastShown < DEDUP_WINDOW) {
|
||||
return -1
|
||||
}
|
||||
recentMessages.set(dedupKey, Date.now())
|
||||
|
||||
const id = nextId++
|
||||
const toast: Toast = {
|
||||
id,
|
||||
|
||||
@@ -69,6 +69,37 @@ const badgeClass = (type: ChangeType) => {
|
||||
}
|
||||
|
||||
const releases: Release[] = [
|
||||
{
|
||||
version: 'v1.8.0',
|
||||
date: '2026-03-03',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers' },
|
||||
{ type: 'feat', text: 'Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d\'éléments par page' },
|
||||
{ type: 'feat', text: 'Compression PDF automatique à l\'upload via Ghostscript, avec commande pour compresser les PDFs existants' },
|
||||
{ type: 'feat', text: 'Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol' },
|
||||
{ type: 'feat', text: 'Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)' },
|
||||
{ type: 'fix', text: 'Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l\'API' },
|
||||
{ type: 'fix', text: 'Édition de squelettes machines : correction du conflit UniqueEntity et de l\'interférence du désérialiseur' },
|
||||
{ type: 'fix', text: 'Sites : ajout de l\'opération PATCH et correction de la migration de contrainte' },
|
||||
{ type: 'chore', text: 'Réorganisation de la navbar avec nouvelles icônes Lucide' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.7.0',
|
||||
date: '2026-03-02',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires' },
|
||||
{ type: 'feat', text: 'Page commentaires centralisée (/comments) avec filtres par statut, type d\'entité, pagination et liens cliquables vers les fiches' },
|
||||
{ type: 'feat', text: 'Badge notifications : compteur de commentaires ouverts sur l\'avatar utilisateur et dans le menu profil (polling 60s)' },
|
||||
{ type: 'feat', text: 'Contrôle d\'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages' },
|
||||
{ type: 'feat', text: 'Journal d\'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions' },
|
||||
{ type: 'feat', text: 'Commande app:init-profile-passwords pour l\'initialisation en masse des mots de passe et rôles' },
|
||||
{ type: 'fix', text: 'Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)' },
|
||||
{ type: 'fix', text: 'Recherche fournisseur : filtrage côté client au lieu d\'appels API debounce' },
|
||||
{ type: 'fix', text: 'Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)' },
|
||||
{ type: 'fix', text: 'Correction de la création de squelettes machines : pagination, duplication, champs personnalisés' },
|
||||
],
|
||||
},
|
||||
{
|
||||
version: 'v1.6.1',
|
||||
date: '2026-02-12',
|
||||
@@ -171,7 +202,7 @@ const releases: Release[] = [
|
||||
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
||||
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
||||
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
|
||||
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification par cookie' },
|
||||
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
||||
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
||||
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
||||
|
||||
@@ -370,6 +370,7 @@ import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
@@ -998,7 +999,7 @@ const submitCreation = async () => {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la création du composant')
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de créer le composant')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
uploadingDocuments.value = false
|
||||
|
||||
@@ -472,6 +472,7 @@ import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||
import IconLucideUser from '~icons/lucide/user'
|
||||
@@ -731,10 +732,10 @@ const confirmDeleteMachine = async (machine) => {
|
||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||
await loadMachines()
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
showError(`Impossible de supprimer la machine : ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
||||
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useMachineTypesApi } from "~/composables/useMachineTypesApi";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
import { humanizeError } from "~/shared/utils/errorMessages";
|
||||
import IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucidePackage from "~icons/lucide/package";
|
||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||
@@ -148,10 +149,10 @@ const confirmDeleteType = async (type) => {
|
||||
if (result.success) {
|
||||
showSuccess(`Type "${type.name}" supprimé avec succès`);
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`);
|
||||
showError(`Impossible de supprimer le type : ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Erreur lors de la suppression: ${error.message}`);
|
||||
showError(`Impossible de supprimer le type : ${humanizeError(error.message)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,6 +138,7 @@ import { useMachines } from '~/composables/useMachines'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||
@@ -214,10 +215,10 @@ const confirmDeleteMachine = async (machine) => {
|
||||
if (result.success) {
|
||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
showError(`Impossible de supprimer la machine : ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
||||
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +315,7 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
@@ -599,7 +600,7 @@ const submitCreation = async () => {
|
||||
toast.showError(result.error)
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la création de la pièce')
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
uploadingDocuments.value = false
|
||||
|
||||
@@ -407,6 +407,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||
@@ -700,7 +701,7 @@ const submitEdition = async () => {
|
||||
await router.push('/product-catalog')
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.showError(error?.message || 'Erreur lors de la mise à jour du produit')
|
||||
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
152
app/shared/utils/errorMessages.ts
Normal file
152
app/shared/utils/errorMessages.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Translates raw backend error messages (Symfony, Doctrine, API Platform)
|
||||
* into user-friendly French messages for display in toasts/alerts.
|
||||
*/
|
||||
|
||||
const EXACT_MATCHES: Record<string, string> = {
|
||||
// UniqueConstraintSubscriber (HTTP 409)
|
||||
'nom duplique': 'Un élément avec ce nom existe déjà.',
|
||||
|
||||
// English backend messages → French
|
||||
'Machine not found.': 'Machine introuvable.',
|
||||
'Composant not found.': 'Composant introuvable.',
|
||||
'Piece not found.': 'Pièce introuvable.',
|
||||
'Product not found.': 'Produit introuvable.',
|
||||
'Site not found.': 'Site introuvable.',
|
||||
'Custom field not found.': 'Champ personnalisé introuvable.',
|
||||
'Custom field value not found.': 'Valeur du champ personnalisé introuvable.',
|
||||
'Document not found.': 'Document introuvable.',
|
||||
'File not found on disk.': 'Le fichier n\'a pas été trouvé sur le serveur.',
|
||||
'Invalid document data.': 'Les données du document sont invalides.',
|
||||
'Invalid JSON payload.': 'Les données envoyées sont invalides.',
|
||||
'Unsupported entity type.': 'Type d\'entité non supporté.',
|
||||
'Entity target is missing.': 'La cible de l\'entité est manquante.',
|
||||
'customFieldId or customFieldName is required.': 'L\'identifiant du champ personnalisé est requis.',
|
||||
|
||||
// Symfony validator messages
|
||||
'This value should not be blank.': 'Ce champ ne peut pas être vide.',
|
||||
'This value is not a valid email address.': 'L\'adresse email n\'est pas valide.',
|
||||
'This value is already used.': 'Cette valeur est déjà utilisée.',
|
||||
'This field is missing.': 'Un champ obligatoire est manquant.',
|
||||
|
||||
// HTTP status texts (used in "Erreur XXX: StatusText" fallback)
|
||||
'Internal Server Error': 'Erreur interne du serveur. Veuillez réessayer.',
|
||||
'Bad Request': 'Requête invalide.',
|
||||
'Not Found': 'Ressource introuvable.',
|
||||
'Conflict': 'Un élément similaire existe déjà.',
|
||||
'Unprocessable Entity': 'Données invalides.',
|
||||
'Unprocessable Content': 'Données invalides.',
|
||||
'Service Unavailable': 'Service temporairement indisponible. Veuillez réessayer.',
|
||||
'Gateway Timeout': 'Le serveur met trop de temps à répondre. Veuillez réessayer.',
|
||||
}
|
||||
|
||||
const TECHNICAL_PATTERNS: Array<[RegExp, string]> = [
|
||||
// Database / Doctrine errors
|
||||
[/SQLSTATE\[/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||
[/An exception occurred/i, 'Une erreur est survenue. Veuillez réessayer.'],
|
||||
[/Duplicate entry/i, 'Un élément avec ces données existe déjà.'],
|
||||
[/unique.*constraint.*violation/i, 'Un élément avec ces données existe déjà.'],
|
||||
[/foreign key constraint/i, 'Impossible de supprimer cet élément car il est utilisé ailleurs.'],
|
||||
[/violates not-null constraint/i, 'Un champ obligatoire n\'a pas été renseigné.'],
|
||||
[/violates check constraint/i, 'Une valeur saisie est invalide.'],
|
||||
|
||||
// Symfony / API Platform internal messages
|
||||
[/Expected argument of type/i, 'Les données envoyées sont invalides.'],
|
||||
[/Could not denormalize/i, 'Les données envoyées sont invalides.'],
|
||||
[/The JSON value could not be decoded/i, 'Les données envoyées sont invalides.'],
|
||||
[/Syntax error.*JSON/i, 'Les données envoyées sont invalides.'],
|
||||
[/No route found/i, 'Ressource introuvable.'],
|
||||
[/Access Denied/i, 'Permissions insuffisantes pour cette action.'],
|
||||
]
|
||||
|
||||
/**
|
||||
* Detects if a message contains technical jargon that should not be shown to users.
|
||||
*/
|
||||
function containsTechnicalJargon(message: string): boolean {
|
||||
const patterns = [
|
||||
/stack trace/i,
|
||||
/exception/i,
|
||||
/\bat\s+[\w\\]+::/,
|
||||
/vendor\//,
|
||||
/\.php/,
|
||||
/doctrine/i,
|
||||
/symfony/i,
|
||||
/SQLSTATE/i,
|
||||
/PDOException/i,
|
||||
/DBALException/i,
|
||||
/RuntimeException/i,
|
||||
/TypeError/i,
|
||||
/LogicException/i,
|
||||
/InvalidArgumentException/i,
|
||||
/UnexpectedValueException/i,
|
||||
/constraint.*violation/i,
|
||||
/entity.*manager/i,
|
||||
/Hydra error/i,
|
||||
]
|
||||
return patterns.some((p) => p.test(message))
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates a raw backend error message into a user-friendly French message.
|
||||
*
|
||||
* Usage:
|
||||
* import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
* showError(humanizeError(rawMessage))
|
||||
*/
|
||||
export function humanizeError(rawMessage: string | undefined | null): string {
|
||||
if (!rawMessage) return 'Une erreur est survenue.'
|
||||
|
||||
const trimmed = rawMessage.trim()
|
||||
if (!trimmed) return 'Une erreur est survenue.'
|
||||
|
||||
// 1. Exact match
|
||||
if (EXACT_MATCHES[trimmed]) return EXACT_MATCHES[trimmed]
|
||||
|
||||
// 2. "Erreur XXX: StatusText" pattern — translate the status text
|
||||
const httpMatch = trimmed.match(/^Erreur (\d{3})\s*:\s*(.+)$/)
|
||||
if (httpMatch) {
|
||||
const statusText = httpMatch[2]!.trim()
|
||||
if (EXACT_MATCHES[statusText]) return EXACT_MATCHES[statusText]
|
||||
return `Erreur serveur (${httpMatch[1]}). Veuillez réessayer.`
|
||||
}
|
||||
|
||||
// 3. Regex patterns for technical errors
|
||||
for (const [pattern, replacement] of TECHNICAL_PATTERNS) {
|
||||
if (pattern.test(trimmed)) return replacement
|
||||
}
|
||||
|
||||
// 4. If it contains technical jargon, replace with generic message
|
||||
if (containsTechnicalJargon(trimmed)) {
|
||||
return 'Une erreur est survenue. Veuillez réessayer.'
|
||||
}
|
||||
|
||||
// 5. Already user-friendly — return as-is
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the best error message from various backend response formats.
|
||||
* Handles: { message }, { error }, { detail }, { "hydra:description" }
|
||||
*/
|
||||
export function extractApiErrorMessage(errorData: Record<string, unknown>): string | null {
|
||||
if (!errorData || typeof errorData !== 'object') return null
|
||||
|
||||
// Symfony validator violations — priorité max (message propre sans préfixe champ)
|
||||
if (Array.isArray(errorData.violations) && errorData.violations.length > 0) {
|
||||
const first = errorData.violations[0] as Record<string, unknown>
|
||||
if (typeof first?.message === 'string') return first.message
|
||||
}
|
||||
|
||||
// UniqueConstraintSubscriber format ({ success: false, error: "nom duplique" })
|
||||
if (typeof errorData.error === 'string') return errorData.error
|
||||
|
||||
// Custom controllers format
|
||||
if (typeof errorData.message === 'string') return errorData.message
|
||||
if (Array.isArray(errorData.message) && typeof errorData.message[0] === 'string') return errorData.message[0]
|
||||
|
||||
// API Platform hydra format (fallback — peut contenir "propertyPath: message")
|
||||
if (typeof errorData['hydra:description'] === 'string') return errorData['hydra:description']
|
||||
if (typeof errorData.detail === 'string') return errorData.detail
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
# Rapport de déduplication
|
||||
|
||||
## DUP-001 · Score 92 · Formulaire de contact site
|
||||
- **Motif** : duplication à l’identique du bloc de champs de contact (nom, téléphone, adresse…) entre les modales de création et d’édition de site.
|
||||
- **Occurrences détectées** :
|
||||
- `app/components/sites/SiteCreateModal.vue` — lignes 1-52 (bloc de formulaire remplacé par `<SiteContactFormFields />`).
|
||||
- `app/components/sites/SiteEditModal.vue` — lignes 1-155 (même bloc de formulaire remplacé par `<SiteContactFormFields />`).
|
||||
- **Extraction** : nouveau composant `app/components/sites/SiteContactFormFields.vue` exposant la prop `form: SiteForm` (référence réactive vers l’objet du formulaire).
|
||||
- **Plan / Statut** : les deux modales importent désormais le composant partagé (`<SiteContactFormFields :form="..." />`), supprimant l’ancienne duplication. Aucun changement d’API publique côté modale.
|
||||
|
||||
## DUP-002 · Score 95 · Éditeur de contraintes (composants/pièces)
|
||||
- **Motif** : logique et template identiques pour la gestion des groupes requis dans `TypeEditComponentRequirementsSection` et `TypeEditPieceRequirementsSection` (ajout/suppression, formulaires, cases à cocher).
|
||||
- **Occurrences détectées** :
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` — lignes 1-94 (ancien template remplacé par `<RequirementListEditor />`).
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` — lignes 1-94 (même duplication remplacée).
|
||||
- **Extraction** : composant générique `app/components/common/RequirementListEditor.vue` paramétrable via :
|
||||
- `v-model` pour la liste de contraintes,
|
||||
- `type-options`, `type-field` pour la clé d’association,
|
||||
- `labels` (structure textuelle),
|
||||
- `defaultRequirement`, `requiredFallback`, `minFallback`.
|
||||
- **Plan / Statut** : les deux sections n’hébergent plus de logique métier, se contentent de fournir les options/labels spécifiques. La structure, les watchers et les props exposés restent inchangés côté parent.
|
||||
|
||||
## DUP-003 · Score 88 · Formatage de dates UI
|
||||
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
|
||||
- **Occurrences détectées** :
|
||||
- `app/pages/component-catalog.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
|
||||
- `app/pages/pieces-catalog.vue` — lignes 70-310.
|
||||
- `app/pages/documents.vue` — lignes 90-188.
|
||||
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
|
||||
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et l’utilisent directement en template. Plus de fonction locale dupliquée.
|
||||
|
||||
## Couverture & suites
|
||||
- Les trois duplications les plus impactantes repérées ont été factorisées (>= 80 % du volume ciblé).
|
||||
- Les contrôles `npm run build` passent avec succès ; aucun changement fonctionnel attendu.
|
||||
- Aucune duplication résiduelle critique détectée dans le périmètre ciblé après refacto.
|
||||
@@ -1,100 +0,0 @@
|
||||
# Micro duplication report
|
||||
|
||||
## MDUP-001 · Score 92 · Type form-field
|
||||
- **Pattern**: Champ téléphone complet (label, input `tel`, placeholder "Ex: 06 00 00 00 00", règles de validation implicites).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L70-L86 — modal de création de constructeur. 【F:app/components/ConstructeurSelect.vue†L66-L88】
|
||||
- `app/pages/constructeurs.vue` L82-L92 — formulaire de création/édition. 【F:app/pages/constructeurs.vue†L80-L92】
|
||||
- `app/components/sites/SiteContactFormFields.vue` L1-L57 — bloc de formulaire de contact. 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
|
||||
- `app/pages/index.vue` L200-L224 — création rapide d’un site. 【F:app/pages/index.vue†L200-L224】
|
||||
- **Extraction**: ✅ `app/components/form/FieldPhone.vue` (props : `modelValue`, `label`, `required`, `error`, `help`, `placeholder`, `disabled`, `normalizeOnBlur`, `validateOnBlur`). 【F:app/components/form/FieldPhone.vue†L1-L113】
|
||||
- **Plan de remplacement**: Remplacer chaque bloc par `<FieldPhone v-model="..." />`. Call-sites déjà migrés ci-dessus.
|
||||
|
||||
## MDUP-002 · Score 90 · Type form-field
|
||||
- **Pattern**: Champ email (label « Email », input `type="email"`, placeholder d’exemple, aucune validation mutualisée).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L66-L84. 【F:app/components/ConstructeurSelect.vue†L66-L86】
|
||||
- `app/pages/constructeurs.vue` L82-L90. 【F:app/pages/constructeurs.vue†L80-L92】
|
||||
- **Extraction**: ✅ `app/components/form/FieldEmail.vue` avec normalisation et validation partagée. 【F:app/components/form/FieldEmail.vue†L1-L112】
|
||||
- **Plan de remplacement**: Blocs remplacés par `<FieldEmail />` sur les deux formulaires.
|
||||
|
||||
## MDUP-003 · Score 88 · Type form-field
|
||||
- **Pattern**: Groupe « informations de contact site » (Nom du contact, Téléphone, Adresse, Code postal, Ville) répliqué.
|
||||
- **Occurrences**:
|
||||
- `app/components/sites/SiteContactFormFields.vue` (composant existant). 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
|
||||
- `app/pages/index.vue` L200-L223 — doublait le bloc dans le modal rapide. 【F:app/pages/index.vue†L200-L223】
|
||||
- **Extraction**: ✅ Réutilisation directe du composant `SiteContactFormFields` sur la page index. Aucun changement d’API.
|
||||
- **Plan de remplacement**: Remplacer le bloc du modal par `<SiteContactFormFields :form="newSite" />` (effectué).
|
||||
|
||||
## MDUP-004 · Score 86 · Type tiny-logic
|
||||
- **Pattern**: Liaisons `computed({ get, set })` pour faire transiter `v-model` entre props et emits.
|
||||
- **Occurrences**:
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditComponentRequirementsSection.vue†L45-L59】
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditPieceRequirementsSection.vue†L45-L59】
|
||||
- `app/components/common/RequirementListEditor.vue` L198-L203. 【F:app/components/common/RequirementListEditor.vue†L198-L204】
|
||||
- `app/components/TypeEditCustomFieldsSection.vue` L163-L168. 【F:app/components/TypeEditCustomFieldsSection.vue†L163-L168】
|
||||
- `app/components/TypeEditBaseInfoSection.vue` L82-L102. 【F:app/components/TypeEditBaseInfoSection.vue†L82-L102】
|
||||
- `app/components/sites/SiteEditModal.vue` L140-L154. 【F:app/components/sites/SiteEditModal.vue†L140-L154】
|
||||
- **Extraction proposée**: `app/composables/useControlledModel.ts` retournant `{ model }` via `useVModel` maison (prop name configurable, options pour defaultValue et transform).
|
||||
- **Plan**: 1) Introduire le composable, 2) remapper les computed existantes, 3) supprimer le code duplicatif.
|
||||
|
||||
## MDUP-005 · Score 82 · Type tiny-logic
|
||||
- **Pattern**: Fonctions `createDefaultRequirement` quasi identiques (seuls champs `minCount`, `required` et `type*Id` changent).
|
||||
- **Occurrences**:
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditComponentRequirementsSection.vue†L61-L69】
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditPieceRequirementsSection.vue†L61-L69】
|
||||
- **Extraction proposée**: `app/shared/requirements/defaults.ts` exportant `createRequirementDefaults({ min, required, typeKey })`.
|
||||
- **Plan**: Mutualiser la fonction, la paramétrer par options, adapter les deux sections.
|
||||
|
||||
## MDUP-006 · Score 80 · Type tiny-logic
|
||||
- **Pattern**: Effet `onMounted` identique qui teste la liste et déclenche `loadX` si vide.
|
||||
- **Occurrences**:
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditComponentRequirementsSection.vue†L89-L93】
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditPieceRequirementsSection.vue†L89-L93】
|
||||
- **Extraction proposée**: `useEnsureOptionsLoaded(optionsRef, loader)` dans `app/composables/` pour encapsuler le check + chargement (support async/await, options pour refetch forcé).
|
||||
- **Plan**: Appeler le composable dans les deux sections et supprimer le code inline.
|
||||
|
||||
## MDUP-007 · Score 78 · Type ui-fragment
|
||||
- **Pattern**: Pieds de modale avec boutons « Annuler » + primaire + spinner optionnel.
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L80-L86. 【F:app/components/ConstructeurSelect.vue†L80-L86】
|
||||
- `app/pages/constructeurs.vue` L86-L91. 【F:app/pages/constructeurs.vue†L86-L91】
|
||||
- `app/components/sites/SiteCreateModal.vue` L21-L27. 【F:app/components/sites/SiteCreateModal.vue†L21-L27】
|
||||
- `app/components/sites/SiteEditModal.vue` L82-L89. 【F:app/components/sites/SiteEditModal.vue†L82-L89】
|
||||
- `app/pages/index.vue` L217-L223 & L306-L312. 【F:app/pages/index.vue†L215-L313】
|
||||
- **Extraction proposée**: `app/components/common/ModalActions.vue` avec props `primaryLabel`, `primaryLoading`, `onCancel`, slots secondaires.
|
||||
- **Plan**: Introduire le composant, refactorer chaque modal pour l’utiliser, garantir les mêmes classes Tailwind.
|
||||
|
||||
## MDUP-008 · Score 76 · Type ui-fragment
|
||||
- **Pattern**: Gabarit de modale (div `.modal` + `.modal-box`, titre `<h3>`, formulaire, actions).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L58-L89. 【F:app/components/ConstructeurSelect.vue†L58-L89】
|
||||
- `app/components/sites/SiteCreateModal.vue` L1-L31. 【F:app/components/sites/SiteCreateModal.vue†L1-L31】
|
||||
- `app/components/sites/SiteEditModal.vue` L1-L94. 【F:app/components/sites/SiteEditModal.vue†L1-L94】
|
||||
- `app/pages/index.vue` L192-L315 (modales site/machine). 【F:app/pages/index.vue†L192-L315】
|
||||
- **Extraction proposée**: `app/components/common/ModalShell.vue` gérant l’ouverture, le titre, le footer via slots (`header`, `default`, `footer`).
|
||||
- **Plan**: Remplacer chaque squelette par le nouveau composant tout en conservant la structure DOM requise par DaisyUI.
|
||||
|
||||
## MDUP-009 · Score 74 · Type form-field
|
||||
- **Pattern**: Champ texte simple (label, input type="text", `required`) pour les « Nom » & co.
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L62-L65. 【F:app/components/ConstructeurSelect.vue†L62-L66】
|
||||
- `app/pages/constructeurs.vue` L78-L81. 【F:app/pages/constructeurs.vue†L78-L81】
|
||||
- `app/components/sites/SiteCreateModal.vue` L6-L17. 【F:app/components/sites/SiteCreateModal.vue†L5-L17】
|
||||
- `app/components/sites/SiteEditModal.vue` L8-L20. 【F:app/components/sites/SiteEditModal.vue†L8-L20】
|
||||
- `app/components/TypeEditBaseInfoSection.vue` L8-L48. 【F:app/components/TypeEditBaseInfoSection.vue†L8-L48】
|
||||
- **Extraction proposée**: `app/components/form/FieldText.vue` avec props `type`, `label`, `required`, `maxlength`, `placeholder`, support `modelModifiers`.
|
||||
- **Plan**: Introduire le composant, migrer progressivement les champs texte, ajouter un paramètre pour afficher l’étoile obligatoire.
|
||||
|
||||
## MDUP-010 · Score 72 · Type ui-fragment
|
||||
- **Pattern**: Bouton primaire avec indicateur de chargement inline (`<span class="loading loading-spinner loading-xs mr-2">`).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L82-L84. 【F:app/components/ConstructeurSelect.vue†L82-L84】
|
||||
- `app/pages/constructeurs.vue` L88-L89. 【F:app/pages/constructeurs.vue†L88-L90】
|
||||
- `app/components/sites/SiteEditModal.vue` L86-L88. 【F:app/components/sites/SiteEditModal.vue†L86-L88】
|
||||
- **Extraction proposée**: `app/components/common/LoadingButton.vue` gérant les variantes (`primary`, `outline`), le spinner et le label via slots.
|
||||
- **Plan**: Remplacer les boutons concernés par le composant, propager `loading` & `disabled` automatiquement.
|
||||
|
||||
## Annexes
|
||||
- **Validations centralisées**: `app/shared/validation/phone.ts` & `app/shared/validation/email.ts` fournissent désormais des schémas communs. 【F:app/shared/validation/phone.ts†L1-L36】【F:app/shared/validation/email.ts†L1-L34】
|
||||
- **Formatters communs**: `app/utils/formatters/phone.ts` et `app/utils/formatters/email.ts` proposent les helpers associés. 【F:app/utils/formatters/phone.ts†L1-L67】【F:app/utils/formatters/email.ts†L1-L37】
|
||||
229
migration.md
229
migration.md
@@ -1,229 +0,0 @@
|
||||
# Plan de migration — Réduction de code frontend
|
||||
|
||||
> Objectif : réduire ~5 700 LOC sans modifier le fonctionnel.
|
||||
> Branche : à partir de `refacto/F1-decoupage-mega-composants`
|
||||
> Statut global : **EN ATTENTE**
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Pages catalogue (3 pages, ~1 200 LOC → ~350 LOC)
|
||||
|
||||
### M1.1 · Composant générique `CatalogPage.vue`
|
||||
|
||||
- **Motif** : `component-catalog.vue` (348 LOC), `pieces-catalog.vue` (463 LOC) et `product-catalog.vue` (408 LOC) partagent 95 % de structure (recherche, tri, pagination, tableau, suppression, états vides/loading).
|
||||
- **Différences isolées** : colonnes du tableau, garde de suppression, extraction fournisseur.
|
||||
- **Plan** :
|
||||
1. Créer `app/components/common/CatalogPage.vue` acceptant :
|
||||
- `columns: ColumnDef[]` (nom, clé, slot optionnel)
|
||||
- `fetchFn: (params) => Promise<PaginatedResult>`
|
||||
- `deleteFn: (id) => Promise<Result>`
|
||||
- `deleteGuard?: (item) => string | null` (message bloquant ou null)
|
||||
- `entityLabel: string`, `createRoute: string`
|
||||
- Slots nommés pour colonnes custom (`#col-supplier`, etc.)
|
||||
2. Extraire `supplierDisplayUtils.ts` (pattern `MAX_VISIBLE_SUPPLIERS` dupliqué dans pieces-catalog et product-catalog).
|
||||
3. Réduire chaque page catalogue à ~80 LOC (config + slots custom).
|
||||
- **Gain estimé** : ~850 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Composables CRUD génériques (~1 170 LOC → ~400 LOC)
|
||||
|
||||
### M2.1 · Factory `useEntityCRUD<T>(config)`
|
||||
|
||||
- **Motif** : `usePieces.ts` (240), `useProducts.ts` (305), `useComposants.ts` (231), `useSites.ts` (124) suivent le même pattern CRUD : refs `loading/loaded/error`, `loadItems()` paginé, `create/update/delete` avec mise à jour cache + toast.
|
||||
- **Différences isolées** : endpoint, normaliseur, enrichissement constructeurs, champs de tri.
|
||||
- **Plan** :
|
||||
1. Créer `app/composables/useEntityCRUD.ts` :
|
||||
```ts
|
||||
interface EntityCRUDConfig {
|
||||
endpoint: string
|
||||
label: string
|
||||
normalizer?: (item: any) => any
|
||||
enricher?: (item: any) => Promise<any>
|
||||
defaultSort?: { field: string; dir: 'asc' | 'desc' }
|
||||
}
|
||||
export function useEntityCRUD(config: EntityCRUDConfig)
|
||||
```
|
||||
2. Extraire `extractTotal()` dans `apiHelpers.ts` (dupliqué 3×, ~10 LOC chacun).
|
||||
3. Extraire `buildPaginatedQuery(options)` dans `apiHelpers.ts` (dupliqué 3×, ~15 LOC chacun).
|
||||
4. Extraire pattern `withResolvedConstructeurs()` dans `useEntityEnricher.ts` (dupliqué 3× dans pieces/products/composants, ~50 LOC chacun).
|
||||
5. Réduire chaque composable à un appel de factory + méthodes spécifiques.
|
||||
6. Garder `useMachines.ts` séparé (méthodes spéciales : `reconfigureSkeleton`, `createMachineFromType`).
|
||||
- **Gain estimé** : ~770 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M2.2 · Helper `withLoadingState()`
|
||||
|
||||
- **Motif** : pattern `loading.value = true; try { ... } finally { loading.value = false }` répété 10+ fois dans les composables CRUD.
|
||||
- **Plan** : créer `app/composables/useLoadingHelper.ts` exportant :
|
||||
```ts
|
||||
async function withLoadingState<T>(loading: Ref<boolean>, fn: () => Promise<T>): Promise<T>
|
||||
```
|
||||
- **Gain estimé** : ~100 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M2.3 · Fusion `usePersistedValue` + `usePersistedSort`
|
||||
|
||||
- **Motif** : même pattern `useCookie()` + `watch()` + JSON parse/stringify.
|
||||
- **Plan** : fusionner en `usePersistedState<T>(key, fallback, prefix?)`.
|
||||
- **Gain estimé** : ~30 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Pages edit entités (~2 750 LOC → ~1 200 LOC)
|
||||
|
||||
### M3.1 · Composant `HistorySection.vue`
|
||||
|
||||
- **Motif** : bloc historique identique (loading/error/empty + itération entries) dans `component/[id]/edit.vue` (L437-503), `pieces/[id]/edit.vue` (L384-450), `product/[id]/edit.vue` (L304-370) — ~67 LOC × 3.
|
||||
- **Plan** : créer `app/components/common/HistorySection.vue` avec props `entries`, `loading`, `error`.
|
||||
- **Gain estimé** : ~130 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M3.2 · Composant `DocumentsSection.vue`
|
||||
|
||||
- **Motif** : bloc document (upload, liste, preview, download, delete) dupliqué dans les 3 pages edit + `MachineDocumentsCard.vue` + `SiteEditModal.vue` — ~70-180 LOC × 5.
|
||||
- **Plan** : créer `app/components/common/DocumentsSection.vue` avec props `documents`, `entityId`, `entityType` et events `upload`, `delete`, `preview`.
|
||||
- **Gain estimé** : ~400 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M3.3 · Composable `useEntityEditForm(config)`
|
||||
|
||||
- **Motif** : les 3 pages edit partagent : chargement entité + types + constructeurs, gestion champs custom, normalisation payload, sauvegarde, gestion erreur.
|
||||
- **Différences** : component a structure display, piece a product selection, product est plus simple.
|
||||
- **Plan** :
|
||||
1. Créer `app/composables/useEntityEditForm.ts` gérant le cycle de vie commun (load, save, custom fields sync).
|
||||
2. Chaque page edit ne garde que ses spécificités.
|
||||
- **Gain estimé** : ~500 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M3.4 · Réutilisation `customFieldFormUtils.ts` dans `component/create.vue`
|
||||
|
||||
- **Motif** : `component/create.vue` (1 266 LOC) réimplémente `resolveFieldName`, `resolveFieldType`, `resolveDefaultValue` déjà dans `customFieldFormUtils.ts`. Aussi 3 fonctions `resolveXxxLabel` quasi-identiques (~18 LOC × 3).
|
||||
- **Plan** :
|
||||
1. Remplacer les fonctions locales par les imports de `customFieldFormUtils.ts`.
|
||||
2. Créer `resolveTypeLabel(entity, typeField, labelField, fallback)` générique.
|
||||
- **Gain estimé** : ~120 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Décomposition `useMachineDetailData.ts` (1 410 LOC → ~500 LOC)
|
||||
|
||||
### M4.1 · Extraire `useMachineDocuments.ts`
|
||||
|
||||
- **Motif** : gestion documents (upload, delete, preview, refresh) = ~200 LOC dans le composable monolithique.
|
||||
- **Gain estimé** : ~150 LOC (après factorisation avec DocumentsSection)
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M4.2 · Extraire `useMachineConstructeurs.ts`
|
||||
|
||||
- **Motif** : résolution constructeurs avec chaînes de fallback 4 niveaux, `uniqueConstructeurIds`, `resolveConstructeurs` = ~80 LOC.
|
||||
- **Gain estimé** : ~60 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M4.3 · Fusionner `transformCustomFields` et `transformComponentCustomFields`
|
||||
|
||||
- **Motif** : L303-405 et L407-514 — logique quasi-identique de transformation des champs custom, seule la source (machine vs composant) diffère.
|
||||
- **Plan** : créer `transformEntityCustomFields(entity, fieldSource, config)` paramétrable.
|
||||
- **Gain estimé** : ~100 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M4.4 · Extraire groupement de requirements
|
||||
|
||||
- **Motif** : `componentRequirementGroups`, `pieceRequirementGroups` = computed complexes avec construction de maps et filtres répétitifs.
|
||||
- **Gain estimé** : ~80 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — `StructureNodeEditor.vue` (1 167 LOC → ~600 LOC)
|
||||
|
||||
### M5.1 · Composable `useDragDrop.ts`
|
||||
|
||||
- **Motif** : 4 handlers drag-drop quasi-identiques (custom fields, pièces, produits, sous-composants) avec chacun `draggingIndex`, `dropTargetIndex`, `reorderClass()`, `handleDragStart/Over/End`.
|
||||
- **Plan** : créer `useDragDrop<T>(items: Ref<T[]>)` retournant `{ dragging, target, reorderClass, onDragStart, onDragOver, onDragEnd, onDrop }`.
|
||||
- **Gain estimé** : ~350 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M5.2 · Extraire validation noeud
|
||||
|
||||
- **Motif** : `isAssignmentNodeComplete` + logique de validation dispersée.
|
||||
- **Plan** : déplacer vers `app/shared/utils/structureValidation.ts`.
|
||||
- **Gain estimé** : ~40 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Micro-duplications restantes (du `micro-dup-report.md`)
|
||||
|
||||
### M6.1 · `useControlledModel.ts` (MDUP-004)
|
||||
|
||||
- **Motif** : `computed({ get, set })` pour transiter `v-model` entre props et emits — dupliqué dans 6 composants.
|
||||
- **Gain estimé** : ~60 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M6.2 · `ModalShell.vue` (MDUP-008) + `ModalActions.vue` (MDUP-007)
|
||||
|
||||
- **Motif** : squelette de modale DaisyUI (`.modal` + `.modal-box` + titre + footer) dupliqué dans 4+ composants. Pieds de modale « Annuler + Primaire + spinner » dupliqués 5×.
|
||||
- **Gain estimé** : ~120 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M6.3 · `LoadingButton.vue` (MDUP-010) + `FieldText.vue` (MDUP-009)
|
||||
|
||||
- **Motif** : bouton primaire avec spinner (3 occurrences), champ texte simple label+input (5 occurrences).
|
||||
- **Gain estimé** : ~80 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
### M6.4 · `createRequirementDefaults` + `useEnsureOptionsLoaded` (MDUP-005, MDUP-006)
|
||||
|
||||
- **Motif** : factory de requirement par défaut + `onMounted` identiques dans les sections composant/pièce.
|
||||
- **Gain estimé** : ~30 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Consolidation custom fields (~1 150 LOC → ~800 LOC)
|
||||
|
||||
### M7.1 · Fusionner logique de résolution dans `customFieldUtils.ts`
|
||||
|
||||
- **Motif** : `customFieldUtils.ts` (440), `entityCustomFieldLogic.ts` (349), `customFieldFormUtils.ts` (367) contiennent des fonctions de résolution de champs qui se chevauchent (`resolveFieldId`, `resolveFieldName`, génération de clé, déduplication).
|
||||
- **Plan** : consolider les fonctions dupliquées en gardant la séparation thématique (utils / form / entity) mais en partageant les primitives.
|
||||
- **Gain estimé** : ~150 LOC
|
||||
- **Statut** : `[ ]`
|
||||
|
||||
---
|
||||
|
||||
## Récapitulatif
|
||||
|
||||
| Phase | Cible | LOC avant | Gain estimé | Priorité |
|
||||
|-------|-------|-----------|-------------|----------|
|
||||
| **P1** | Pages catalogue | ~1 220 | ~850 | Haute |
|
||||
| **P2** | Composables CRUD | ~1 170 | ~900 | Haute |
|
||||
| **P3** | Pages edit entités | ~2 750 | ~1 150 | Haute |
|
||||
| **P4** | useMachineDetailData | ~1 410 | ~390 | Moyenne |
|
||||
| **P5** | StructureNodeEditor | ~1 167 | ~390 | Moyenne |
|
||||
| **P6** | Micro-duplications | ~400 | ~290 | Basse |
|
||||
| **P7** | Custom fields utils | ~1 150 | ~150 | Basse |
|
||||
| | **Total** | | **~4 120 LOC** | |
|
||||
|
||||
### Ordre recommandé
|
||||
|
||||
1. **P2** (CRUD generics) — fondation pour P1 et P3
|
||||
2. **P1** (catalogues) — dépend de P2 pour les fetch functions
|
||||
3. **P3** (pages edit) — plus gros gain absolu, dépend partiellement de P2
|
||||
4. **P5** (drag-drop) — indépendant, quick win
|
||||
5. **P4** (machine detail) — complexe mais fort impact
|
||||
6. **P6** (micro-dup) — petits gains, faible risque
|
||||
7. **P7** (custom fields) — délicat, à faire en dernier
|
||||
|
||||
### Vérification après chaque phase
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
npx nuxi typecheck # 0 erreurs
|
||||
npm run lint:fix # 0 erreurs
|
||||
npm run build # succès
|
||||
npx vitest run # 54+ tests pass
|
||||
```
|
||||
Reference in New Issue
Block a user