diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 3f20c17..5560180 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -416,7 +416,6 @@ "newCategory": "Ajouter", "editCategory": "Modifier la catégorie", "createCategory": "Créer une catégorie", - "viewCategory": "Détail de la catégorie", "noCategories": "Aucune catégorie pour l'instant.", "table": { "name": "Nom", @@ -431,8 +430,7 @@ }, "form": { "name": "Nom", - "types": "Types de catégorie", - "typesPlaceholder": "Sélectionner un ou plusieurs types" + "types": "Types de catégorie" }, "validation": { "nameRequired": "Le nom est obligatoire.", diff --git a/frontend/modules/catalog/components/CategoryDrawer.vue b/frontend/modules/catalog/components/CategoryDrawer.vue index 1a77541..4f5c939 100644 --- a/frontend/modules/catalog/components/CategoryDrawer.vue +++ b/frontend/modules/catalog/components/CategoryDrawer.vue @@ -31,7 +31,6 @@ v-model="form.categoryTypeIds.value" :options="typeOptions" :label="t('admin.categories.form.types')" - :empty-option-label="t('admin.categories.form.typesPlaceholder')" :error="form.errors.categoryTypes" :display-tag="true" :disabled="loadingTypes" @@ -91,28 +90,17 @@ const emit = defineEmits<{ delete: [] }>() -/** - * Mode du drawer (dérivé du composable `useCategoryForm`) : - * - 'create' : pas de category prop, formulaire vide, POST au save. - * - 'view' : category prop set, formulaire pre-rempli, save MASQUE - * jusqu'a ce que l'utilisateur modifie un champ. - * - 'edit' : category prop set et formulaire « dirty » (au moins un - * champ different de l'original), PATCH au save. - */ -type DrawerMode = 'create' | 'view' | 'edit' - +// Mode du drawer : creation (pas de category prop, POST au save) ou +// modification d'une categorie existante (PATCH au save). Pas de distinction +// view/edit : comme les autres drawers, le titre et le bouton Enregistrer sont +// stables quel que soit l'etat « dirty » du formulaire. const isCreateMode = computed(() => props.category === null) -const mode = computed(() => { - if (isCreateMode.value) return 'create' - return form.isDirty.value ? 'edit' : 'view' -}) - -const headerLabel = computed(() => { - if (mode.value === 'create') return t('admin.categories.createCategory') - if (mode.value === 'edit') return t('admin.categories.editCategory') - return t('admin.categories.viewCategory') -}) +const headerLabel = computed(() => + isCreateMode.value + ? t('admin.categories.createCategory') + : t('admin.categories.editCategory'), +) // Le bouton Supprimer n'est visible qu'en consultation/edition d'une categorie // existante et seulement pour les users ayant la permission manage. En mode @@ -121,10 +109,12 @@ const canShowDelete = computed( () => !isCreateMode.value && can('catalog.categories.manage'), ) -// Save : visible en creation, ou en edition (apres modification d'un champ). -// Masque en view tant que rien n'a change. +// Save : visible en creation, et en consultation/edition d'une categorie +// existante (l'utilisateur doit pouvoir enregistrer sans qu'un champ ait +// d'abord ete modifie). Le bouton reste neanmoins protege par son `disabled` +// pendant la soumission / le chargement des types. const canShowSave = computed( - () => mode.value === 'create' || mode.value === 'edit', + () => isCreateMode.value || can('catalog.categories.manage'), ) const typeOptions = computed(() => @@ -154,18 +144,18 @@ watch( ) /** - * Sauvegarde : delegue au composable (POST en mode create, PATCH en mode - * edit). Le toast succes + mapping erreur 409/422 est gere par le composable. - * En cas de succes, on ferme le drawer et on previent le parent pour qu'il - * refresh la liste. + * Sauvegarde : delegue au composable (POST en creation, PATCH en modification). + * Le toast succes + mapping erreur 409/422 est gere par le composable. Le PATCH + * envoie le payload complet, donc le bouton Enregistrer sauvegarde a tout + * moment (meme sans modification). En cas de succes, on ferme le drawer et on + * previent le parent pour qu'il refresh la liste. */ async function handleSave(): Promise { - let result: Category | null = null - if (mode.value === 'create') { - result = await form.submitCreate() - } else if (mode.value === 'edit' && props.category) { - result = await form.submitUpdate(props.category.id) - } + const result = isCreateMode.value + ? await form.submitCreate() + : props.category + ? await form.submitUpdate(props.category.id) + : null if (result) { emit('saved') emit('update:modelValue', false) diff --git a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts index c46cffe..2305ca7 100644 --- a/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts +++ b/frontend/modules/catalog/composables/__tests__/useCategoryForm.spec.ts @@ -346,7 +346,7 @@ describe('useCategoryForm', () => { }) describe('submitUpdate', () => { - it('appelle PATCH /categories/{id} uniquement avec les champs modifies', async () => { + it('appelle PATCH /categories/{id} avec le payload complet (name + categoryTypes)', async () => { mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' }) const form = useCategoryForm() form.loadFrom(CAT) @@ -354,9 +354,11 @@ describe('useCategoryForm', () => { await form.submitUpdate(42) + // Payload complet : meme si seul le name change, on renvoie aussi + // les categoryTypes (PATCH full payload, cf. drawers simples). expect(mockPatch).toHaveBeenCalledWith( '/categories/42', - { name: 'Vis V2' }, // pas de categoryTypes car non modifies + { name: 'Vis V2', categoryTypes: ['/api/category_types/1'] }, { toast: false }, ) }) @@ -371,20 +373,25 @@ describe('useCategoryForm', () => { expect(mockPatch).toHaveBeenCalledWith( '/categories/42', - { categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, + { name: CAT.name, categoryTypes: ['/api/category_types/1', '/api/category_types/2'] }, { toast: false }, ) }) - it('court-circuite l appel API si aucun champ n a change', async () => { + it('envoie un PATCH complet meme sans modification (save a tout moment)', async () => { + mockPatch.mockResolvedValueOnce(CAT) const form = useCategoryForm() form.loadFrom(CAT) - // Aucune modification — isDirty=false, patch payload vide. + // Aucune modification : le PATCH part quand meme avec le payload complet. const result = await form.submitUpdate(42) - expect(mockPatch).not.toHaveBeenCalled() - expect(result).toBeNull() + expect(mockPatch).toHaveBeenCalledWith( + '/categories/42', + { name: CAT.name, categoryTypes: ['/api/category_types/1'] }, + { toast: false }, + ) + expect(result).toEqual(CAT) expect(form.submitting.value).toBe(false) }) diff --git a/frontend/modules/catalog/composables/useCategoryForm.ts b/frontend/modules/catalog/composables/useCategoryForm.ts index 2fadf5e..71bbd8a 100644 --- a/frontend/modules/catalog/composables/useCategoryForm.ts +++ b/frontend/modules/catalog/composables/useCategoryForm.ts @@ -174,26 +174,18 @@ export function useCategoryForm() { } /** - * PATCH /api/categories/{id}. Envoie uniquement les champs modifies pour - * coller a la semantique merge-patch (Content-Type pose par useApi). - * Renvoie la categorie mise a jour, ou `null` en cas d'echec. + * PATCH /api/categories/{id}. Envoie le payload complet (name + + * categoryTypes), comme les autres drawers du projet : le bouton + * Enregistrer sauvegarde a tout moment, meme sans modification, et renvoie + * toujours un retour (toast succes + refresh). Renvoie la categorie mise a + * jour, ou `null` en cas d'echec. */ async function submitUpdate(id: number): Promise { if (!validate()) return null submitting.value = true - const payload: Record = {} - if (name.value !== initialName.value) { - payload.name = name.value.trim() - } - if (!sameIds(categoryTypeIds.value, initialCategoryTypeIds.value)) { - payload.categoryTypes = categoryTypeIds.value.map(id => `/api/category_types/${id}`) - } - // Garde-fou : un PATCH sans changement ne sert a rien. Theoriquement - // empeche par le drawer (bouton Enregistrer masque si !isDirty) mais - // on protege le composable contre un appel direct mal utilise. - if (Object.keys(payload).length === 0) { - submitting.value = false - return null + const payload: Record = { + name: name.value.trim(), + categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`), } try { const updated = await api.patch(`/categories/${id}`, payload, { diff --git a/frontend/modules/catalog/pages/admin/categories.vue b/frontend/modules/catalog/pages/admin/categories.vue index 784b16e..76d0bbb 100644 --- a/frontend/modules/catalog/pages/admin/categories.vue +++ b/frontend/modules/catalog/pages/admin/categories.vue @@ -3,17 +3,10 @@ {{ t('admin.categories.title') }} diff --git a/frontend/modules/commercial/pages/clients/index.vue b/frontend/modules/commercial/pages/clients/index.vue index 5c624cc..013c0ee 100644 --- a/frontend/modules/commercial/pages/clients/index.vue +++ b/frontend/modules/commercial/pages/clients/index.vue @@ -12,8 +12,7 @@ :label="filterButtonLabel" icon-name="mdi:tune" icon-position="left" - icon-size="20" - button-class="w-[180px] justify-start gap-4 text-black" + icon-size="24" @click="openFilters" /> diff --git a/frontend/modules/core/pages/admin/roles.vue b/frontend/modules/core/pages/admin/roles.vue index 915945b..86b9d11 100644 --- a/frontend/modules/core/pages/admin/roles.vue +++ b/frontend/modules/core/pages/admin/roles.vue @@ -5,6 +5,7 @@