feat(catalog) : categories multi-types (M:N) + filtres liste
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 51s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m9s

Volet A — relation Category <-> CategoryType passee de ManyToOne a ManyToMany
(jonction category_category_type). Au moins un type obligatoire (Assert\Count),
unicite du nom desormais GLOBALE parmi les actifs (uq_category_name_active).
Migration avec backfill + drop de l'ancienne colonne. Contrat Shared
CategoryInterface : getCategoryTypeCode() -> getCategoryTypeCodes(): array ;
RG-2.10 fournisseurs (Supplier / SupplierAddress / fixtures) revalident
« contient FOURNISSEUR ». Provider/Repository : filtre type via sous-requete
EXISTS (sans tronquer la collection embarquee), eager-load anti-N+1.

Volet B — bouton « Filtres » sur la liste des categories (recherche nom +
types multi en OR), sur le modele du Repertoire Clients ; etat local, jamais
persiste en URL. Filtres back ?name= et ?typeId[]= sur la collection.

Front : multi-select MalioSelectCheckbox, useCategoryForm en categoryTypeIds[],
colonne « Types », i18n. ColumnCommentsCatalog + makefile test-db-setup alignes
sur le nouvel index partiel. Tests Catalog/Commercial adaptes + CategoryFilterTest.
This commit is contained in:
Matthieu
2026-06-08 11:26:07 +02:00
parent 43b2251ef1
commit f813c1732e
31 changed files with 909 additions and 257 deletions
@@ -24,16 +24,18 @@
required
/>
<!-- Type (RG-1.05 obligatoire). MalioSelect porte la valeur en
number (categoryType id) ; conversion en IRI au moment du save
par le composable useCategoryForm. -->
<MalioSelect
v-model="form.categoryTypeId.value"
<!-- Types (RG-1.05 : au moins un obligatoire). MalioSelectCheckbox
porte un tableau d'ids (categoryType id) ; conversion en tableau
d'IRI au moment du save par le composable useCategoryForm. -->
<MalioSelectCheckbox
v-model="form.categoryTypeIds.value"
:options="typeOptions"
:label="t('admin.categories.form.type')"
:empty-option-label="t('admin.categories.form.typePlaceholder')"
:error="form.errors.categoryType"
:label="t('admin.categories.form.types')"
:empty-option-label="t('admin.categories.form.typesPlaceholder')"
:error="form.errors.categoryTypes"
:display-tag="true"
:disabled="loadingTypes"
required
/>
</form>
@@ -39,7 +39,7 @@ const TYPE_ACHAT: CategoryType = { id: 2, code: 'ACHAT', label: 'Achat' }
const CAT: Category = {
id: 42,
name: 'Vis',
categoryType: TYPE_VENTE,
categoryTypes: [TYPE_VENTE],
deletedAt: null,
createdAt: '2026-01-01T10:00:00+00:00',
updatedAt: '2026-01-01T10:00:00+00:00',
@@ -58,25 +58,25 @@ describe('useCategoryForm', () => {
})
describe('loadFrom', () => {
it('pre-remplit le formulaire depuis une categorie existante', () => {
it('pre-remplit le formulaire depuis une categorie existante (multi-types)', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
expect(form.name.value).toBe('Vis')
expect(form.categoryTypeId.value).toBe(1)
expect(form.categoryTypeIds.value).toEqual([1, 2])
expect(form.errors).toEqual({})
})
it('vide le formulaire en mode creation (null)', () => {
const form = useCategoryForm()
form.name.value = 'old'
form.categoryTypeId.value = 99
form.categoryTypeIds.value = [99]
form.loadFrom(null)
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.categoryTypeIds.value).toEqual([])
})
it('reinitialise le snapshot initial → isDirty=false juste apres', () => {
@@ -98,13 +98,32 @@ describe('useCategoryForm', () => {
expect(form.isDirty.value).toBe(true)
})
it('passe a true quand on ajoute un type (selection multi)', () => {
const form = useCategoryForm()
form.loadFrom(CAT)
expect(form.isDirty.value).toBe(false)
form.categoryTypeIds.value = [1, 2]
expect(form.isDirty.value).toBe(true)
})
it('reste false si la selection est identique dans un autre ordre', () => {
const form = useCategoryForm()
form.loadFrom({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
form.categoryTypeIds.value = [2, 1]
expect(form.isDirty.value).toBe(false)
})
})
describe('validate', () => {
it('signale une erreur si name est vide (RG-1.02)', () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -115,7 +134,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name est whitespace-only (trim → vide)', () => {
const form = useCategoryForm()
form.name.value = ' '
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -126,7 +145,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 1 caractere (< 2, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -137,7 +156,7 @@ describe('useCategoryForm', () => {
it('signale erreur si name fait 121 caracteres (> 120, RG-1.04)', () => {
const form = useCategoryForm()
form.name.value = 'A'.repeat(121)
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const ok = form.validate()
@@ -145,21 +164,21 @@ describe('useCategoryForm', () => {
expect(form.errors.name).toBe('admin.categories.validation.nameLength')
})
it('signale erreur si categoryTypeId est null (RG-1.05)', () => {
it('signale erreur si aucun type selectionne (RG-1.05)', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = null
form.categoryTypeIds.value = []
const ok = form.validate()
expect(ok).toBe(false)
expect(form.errors.categoryType).toBe('admin.categories.validation.typeRequired')
expect(form.errors.categoryTypes).toBe('admin.categories.validation.typesRequired')
})
it('passe quand name et categoryType sont valides', () => {
it('passe quand name et au moins un type sont valides', () => {
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1, 2]
const ok = form.validate()
@@ -171,7 +190,7 @@ describe('useCategoryForm', () => {
const form = useCategoryForm()
// Erreur prealable : une validation en echec peuple errors.name.
form.name.value = ''
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
form.validate()
expect(form.errors.name).toBeTruthy()
@@ -184,17 +203,17 @@ describe('useCategoryForm', () => {
})
describe('submitCreate', () => {
it('appelle POST /categories avec body { name trimme, categoryType en IRI }', async () => {
it('appelle POST /categories avec body { name trimme, categoryTypes en IRI[] }', async () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = ' Vis '
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1, 2]
const result = await form.submitCreate()
expect(mockPost).toHaveBeenCalledWith(
'/categories',
{ name: 'Vis', categoryType: '/api/category_types/1' },
{ name: 'Vis', categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
expect(result).toEqual(CAT)
@@ -203,7 +222,7 @@ describe('useCategoryForm', () => {
it('ne declenche aucun appel API si la validation client echoue', async () => {
const form = useCategoryForm()
form.name.value = ''
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -215,7 +234,7 @@ describe('useCategoryForm', () => {
mockPost.mockResolvedValueOnce(CAT)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
await form.submitCreate()
@@ -231,7 +250,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -258,7 +277,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const result = await form.submitCreate()
@@ -269,24 +288,24 @@ describe('useCategoryForm', () => {
expect(mockToastError).not.toHaveBeenCalled()
})
it('mappe aussi hydra:violations (negociation de format alternative)', async () => {
it('mappe une violation sur categoryTypes (hydra:violations alternative)', async () => {
mockPost.mockRejectedValueOnce({
response: {
status: 422,
_data: {
'hydra:violations': [
{ propertyPath: 'categoryType', message: 'Type invalide.' },
{ propertyPath: 'categoryTypes', message: 'Sélectionnez au moins un type de catégorie.' },
],
},
},
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
await form.submitCreate()
expect(form.errors.categoryType).toBe('Type invalide.')
expect(form.errors.categoryTypes).toBe('Sélectionnez au moins un type de catégorie.')
})
it('fallback en toast generique si le status n est ni 409 ni 422', async () => {
@@ -295,7 +314,7 @@ describe('useCategoryForm', () => {
})
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
await form.submitCreate()
@@ -314,7 +333,7 @@ describe('useCategoryForm', () => {
)
const form = useCategoryForm()
form.name.value = 'Vis'
form.categoryTypeId.value = 1
form.categoryTypeIds.value = [1]
const pending = form.submitCreate()
expect(form.submitting.value).toBe(true)
@@ -331,28 +350,28 @@ describe('useCategoryForm', () => {
mockPatch.mockResolvedValueOnce({ ...CAT, name: 'Vis V2' })
const form = useCategoryForm()
form.loadFrom(CAT)
form.name.value = 'Vis V2' // categoryTypeId inchange
form.name.value = 'Vis V2' // types inchanges
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ name: 'Vis V2' }, // pas de categoryType car non modifie
{ name: 'Vis V2' }, // pas de categoryTypes car non modifies
{ toast: false },
)
})
it('envoie categoryType en IRI quand seul le type a change', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryType: TYPE_ACHAT })
it('envoie categoryTypes en IRI[] quand on ajoute un type', async () => {
mockPatch.mockResolvedValueOnce({ ...CAT, categoryTypes: [TYPE_VENTE, TYPE_ACHAT] })
const form = useCategoryForm()
form.loadFrom(CAT)
form.categoryTypeId.value = 2
form.categoryTypeIds.value = [1, 2]
await form.submitUpdate(42)
expect(mockPatch).toHaveBeenCalledWith(
'/categories/42',
{ categoryType: '/api/category_types/2' },
{ categoryTypes: ['/api/category_types/1', '/api/category_types/2'] },
{ toast: false },
)
})
@@ -438,7 +457,7 @@ describe('useCategoryForm', () => {
form.reset()
expect(form.name.value).toBe('')
expect(form.categoryTypeId.value).toBeNull()
expect(form.categoryTypeIds.value).toEqual([])
expect(form.errors).toEqual({})
expect(form.submitting.value).toBe(false)
})
@@ -13,9 +13,10 @@
* revalide toujours (defense en profondeur).
*
* Erreurs par champ : delegue a `useFormErrors` (convention ERP-101). Les
* violations 422 sont mappees par `propertyPath` (`name`, `categoryType`) ;
* violations 422 sont mappees par `propertyPath` (`name`, `categoryTypes`) ;
* l'erreur globale (status != 422 exploitable) part en toast. Le 409 (doublon
* RG-1.07) reste un cas metier specifique : erreur inline sur `name` + toast.
* de nom GLOBAL, RG-1.07) reste un cas metier specifique : erreur inline sur
* `name` + toast.
*/
import { computed, ref } from 'vue'
import type { Category } from '~/modules/catalog/types/category'
@@ -42,20 +43,29 @@ export function useCategoryForm() {
// State local du formulaire — pas singleton, chaque appel a useCategoryForm
// cree son propre state (cohérent avec le pattern « un drawer = un form »).
const name = ref('')
const categoryTypeId = ref<number | null>(null)
const categoryTypeIds = ref<number[]>([])
// Snapshot des valeurs initiales : sert a calculer `isDirty` pour le
// pattern view → edit du drawer (le bouton Enregistrer reste masque tant
// que rien n'a change en mode consultation).
const initialName = ref('')
const initialCategoryTypeId = ref<number | null>(null)
const initialCategoryTypeIds = ref<number[]>([])
const submitting = ref(false)
// Compare deux listes d'ids sans tenir compte de l'ordre (la selection
// multi-types n'est pas ordonnee).
function sameIds(a: number[], b: number[]): boolean {
if (a.length !== b.length) return false
const sortedA = [...a].sort((x, y) => x - y)
const sortedB = [...b].sort((x, y) => x - y)
return sortedA.every((v, i) => v === sortedB[i])
}
const isDirty = computed(
() =>
name.value !== initialName.value
|| categoryTypeId.value !== initialCategoryTypeId.value,
|| !sameIds(categoryTypeIds.value, initialCategoryTypeIds.value),
)
/**
@@ -66,15 +76,16 @@ export function useCategoryForm() {
function loadFrom(category: Category | null): void {
formErrors.clearErrors()
if (category) {
const ids = category.categoryTypes.map(t => t.id)
name.value = category.name
categoryTypeId.value = category.categoryType.id
categoryTypeIds.value = [...ids]
initialName.value = category.name
initialCategoryTypeId.value = category.categoryType.id
initialCategoryTypeIds.value = [...ids]
} else {
name.value = ''
categoryTypeId.value = null
categoryTypeIds.value = []
initialName.value = ''
initialCategoryTypeId.value = null
initialCategoryTypeIds.value = []
}
}
@@ -95,23 +106,23 @@ export function useCategoryForm() {
formErrors.setError('name', t('admin.categories.validation.nameLength'))
}
// RG-1.05 — categoryType obligatoire.
if (categoryTypeId.value === null) {
formErrors.setError('categoryType', t('admin.categories.validation.typeRequired'))
// RG-1.05 — au moins un type obligatoire.
if (categoryTypeIds.value.length === 0) {
formErrors.setError('categoryTypes', t('admin.categories.validation.typesRequired'))
}
return !formErrors.errors.name && !formErrors.errors.categoryType
return !formErrors.errors.name && !formErrors.errors.categoryTypes
}
/**
* Construit le payload POST a partir du state. Le `categoryType` est
* envoye en IRI Hydra (`/api/category_types/{id}`) — convention API
* Platform pour referencer une ressource liee.
* Construit le payload POST a partir du state. Les `categoryTypes` sont
* envoyes en tableau d'IRI Hydra (`/api/category_types/{id}`) — convention
* API Platform pour referencer une collection de ressources liees.
*/
function buildCreatePayload(): Record<string, unknown> {
return {
name: name.value.trim(),
categoryType: `/api/category_types/${categoryTypeId.value}`,
categoryTypes: categoryTypeIds.value.map(id => `/api/category_types/${id}`),
}
}
@@ -174,8 +185,8 @@ export function useCategoryForm() {
if (name.value !== initialName.value) {
payload.name = name.value.trim()
}
if (categoryTypeId.value !== initialCategoryTypeId.value) {
payload.categoryType = `/api/category_types/${categoryTypeId.value}`
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
@@ -233,9 +244,9 @@ export function useCategoryForm() {
*/
function reset(): void {
name.value = ''
categoryTypeId.value = null
categoryTypeIds.value = []
initialName.value = ''
initialCategoryTypeId.value = null
initialCategoryTypeIds.value = []
formErrors.clearErrors()
submitting.value = false
}
@@ -243,7 +254,7 @@ export function useCategoryForm() {
return {
// State
name,
categoryTypeId,
categoryTypeIds,
errors: formErrors.errors,
submitting,
isDirty,
@@ -3,13 +3,28 @@
<PageHeader>
{{ t('admin.categories.title') }}
<template #actions>
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
<!-- gap-12 = 48px d'espacement entre Ajouter et Filtres (meme
design que le Repertoire Clients). -->
<div class="flex items-center gap-12">
<MalioButton
v-if="canManage"
:label="t('admin.categories.newCategory')"
icon-name="mdi:add-bold"
icon-position="left"
@click="openCreateDrawer"
/>
<!-- Bouton Filtres a DROITE d'Ajouter. Le compteur reflete
les filtres actifs. -->
<MalioButton
variant="tertiary"
:label="filterButtonLabel"
icon-name="mdi:tune"
icon-position="left"
icon-size="24"
button-class="w-[184px] justify-start gap-4 text-black"
@click="openFilters"
/>
</div>
</template>
</PageHeader>
@@ -47,6 +62,60 @@
:loading="deleting"
@confirm="handleDelete"
/>
<!-- Drawer de filtres : etat BROUILLON, applique uniquement au clic sur
« Appliquer ». Meme pattern que le Repertoire Clients. Etat 100 %
local, jamais dans l'URL (regle ABSOLUE n°6). -->
<MalioDrawer
v-model="filterDrawerOpen"
drawer-class="max-w-[450px]"
body-class="p-0"
footer-class="justify-between border-t border-black p-6"
>
<template #header>
<h2 class="text-[24px] font-bold uppercase">{{ t('admin.categories.filters.title') }}</h2>
</template>
<MalioAccordion>
<!-- Recherche par nom (param `name`, partiel insensible a la casse). -->
<MalioAccordionItem :title="t('admin.categories.filters.search')" value="search">
<MalioInputText
v-model="draftSearch"
icon-name="mdi:magnify"
/>
</MalioAccordionItem>
<!-- Type(s) : cases a cocher (multi). Une categorie remonte si
elle porte AU MOINS UN des types coches (OR cote back). -->
<MalioAccordionItem :title="t('admin.categories.filters.types')" value="types">
<div class="flex flex-col">
<MalioCheckbox
v-for="opt in typeFilterOptions"
:id="`filter-type-${opt.value}`"
:key="opt.value"
:label="opt.label"
:model-value="draftTypeIds.includes(opt.value)"
@update:model-value="(val: boolean) => toggleType(opt.value, val)"
/>
</div>
</MalioAccordionItem>
</MalioAccordion>
<template #footer>
<MalioButton
variant="tertiary"
:label="t('admin.categories.filters.reset')"
button-class="w-m-btn-action"
@click="resetFilters"
/>
<MalioButton
variant="primary"
:label="t('admin.categories.filters.apply')"
button-class="w-[170px]"
@click="applyFilters"
/>
</template>
</MalioDrawer>
</div>
</template>
@@ -55,7 +124,7 @@ import type { Category } from '~/modules/catalog/types/category'
const { t } = useI18n()
const { can } = usePermissions()
const { fetchTypes } = useCategoriesAdmin()
const { types, fetchTypes } = useCategoriesAdmin()
const { submitDelete } = useCategoryForm()
useHead({ title: t('admin.categories.title') })
@@ -74,6 +143,7 @@ const {
fetch: fetchCategories,
goToPage,
setItemsPerPage,
setFilters,
} = usePaginatedList<Category>({ url: '/categories' })
const drawerOpen = ref(false)
@@ -82,21 +152,96 @@ const deleteModalOpen = ref(false)
const categoryToDelete = ref<Category | null>(null)
const deleting = ref(false)
// Colonnes du datatable. Le type est embarque cote API (cf. spec-back § 3.4) —
// on aplatit en label lisible pour l'affichage.
// Colonnes du datatable. Les types sont embarques cote API (ManyToMany) — on
// aplatit en libelles joints par une virgule pour l'affichage.
const columns = [
{ key: 'name', label: t('admin.categories.table.name') },
{ key: 'typeLabel', label: t('admin.categories.table.type') },
{ key: 'typesLabel', label: t('admin.categories.table.types') },
]
const categoryItems = computed(() =>
categories.value.map(cat => ({
id: cat.id,
name: cat.name,
typeLabel: cat.categoryType?.label ?? '',
typesLabel: (cat.categoryTypes ?? []).map(ct => ct.label).join(', '),
})),
)
// ── Filtres (drawer) ────────────────────────────────────────────────────────
// Deux niveaux d'etat (pattern Repertoire Clients) :
// - APPLIED : pilote la liste + le compteur du bouton. Modifie uniquement au
// clic « Appliquer » / « Réinitialiser ».
// - DRAFT : edite librement dans le drawer ; recopie vers applied a la validation.
const filterDrawerOpen = ref(false)
const draftSearch = ref('')
const draftTypeIds = ref<number[]>([])
const appliedSearch = ref('')
const appliedTypeIds = ref<number[]>([])
// Options du filtre Type(s), derivees du referentiel deja charge (fetchTypes).
const typeFilterOptions = computed(() =>
types.value.map(ct => ({ value: ct.id, label: ct.label })),
)
const activeFilterCount = computed(() => {
let count = 0
if (appliedSearch.value.trim() !== '') count++
if (appliedTypeIds.value.length > 0) count++
return count
})
const filterButtonLabel = computed(() => {
const base = t('admin.categories.filters.title')
return activeFilterCount.value > 0 ? `${base} (${activeFilterCount.value})` : base
})
// Recopie l'etat applique vers le brouillon puis ouvre le drawer.
function openFilters(): void {
draftSearch.value = appliedSearch.value
draftTypeIds.value = [...appliedTypeIds.value]
filterDrawerOpen.value = true
}
function toggleType(id: number, selected: boolean): void {
draftTypeIds.value = selected
? [...draftTypeIds.value, id]
: draftTypeIds.value.filter(t => t !== id)
}
/**
* Construit le payload de filtres serveur a partir de l'etat applique. Cle
* `typeId[]` pour que PHP la parse en tableau (OR cote back). Filtres vides omis.
*/
function buildFilterPayload(): Record<string, string | string[]> {
const payload: Record<string, string | string[]> = {}
if (appliedSearch.value.trim() !== '') payload.name = appliedSearch.value.trim()
if (appliedTypeIds.value.length > 0) payload['typeId[]'] = appliedTypeIds.value.map(String)
return payload
}
// « Appliquer » : recopie brouillon → applied, pousse les filtres (retombe en
// page 1 via usePaginatedList) et ferme le drawer.
function applyFilters(): void {
appliedSearch.value = draftSearch.value.trim()
appliedTypeIds.value = [...draftTypeIds.value]
setFilters(buildFilterPayload(), { replace: true })
filterDrawerOpen.value = false
}
// « Réinitialiser » : vide brouillon ET applied, recharge la liste complete.
// Le drawer reste ouvert pour montrer le formulaire vide.
function resetFilters(): void {
draftSearch.value = ''
draftTypeIds.value = []
appliedSearch.value = ''
appliedTypeIds.value = []
setFilters({}, { replace: true })
}
function getCategoryById(id: number): Category | undefined {
return categories.value.find(c => c.id === id)
}
+11 -10
View File
@@ -4,15 +4,15 @@
* Contrats API consommes :
* - GET /api/categories → HydraCollection<Category>
* - GET /api/categories/{id} → Category
* - POST /api/categories → body { name, categoryType: IRI }
* - PATCH /api/categories/{id} → body partiel { name?, categoryType?: IRI }
* - POST /api/categories → body { name, categoryTypes: IRI[] }
* - PATCH /api/categories/{id} → body partiel { name?, categoryTypes?: IRI[] }
* - DELETE /api/categories/{id} → 204 (soft delete via CategoryProcessor)
* - GET /api/category_types → HydraCollection<CategoryType>
*
* Notes :
* - Les IRI sont envoyes en POST/PATCH (ex. "/api/category_types/3").
* - `categoryType` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType, cf. spec-back § 3.4).
* - Les IRI sont envoyes en POST/PATCH (ex. ["/api/category_types/3"]).
* - `categoryTypes` est embarque (groupe Serializer `category:read` sur les
* proprietes de CategoryType) : tableau d'objets type en lecture.
* - `createdBy` / `updatedBy` peuvent etre `null` (hors contexte HTTP,
* ON DELETE SET NULL en BDD). Affichage : libelle "Systeme" si null.
*/
@@ -43,7 +43,8 @@ export interface CategoryType {
export interface Category {
id: number
name: string
categoryType: CategoryType
/** Types de la categorie (>= 1, ManyToMany embarque en lecture). */
categoryTypes: CategoryType[]
/** Soft delete : null = active, valeur = supprimee logiquement le {date}. */
deletedAt: string | null
createdAt: string
@@ -53,12 +54,12 @@ export interface Category {
}
/**
* Payload accepte en POST /api/categories. `categoryType` est envoye en
* IRI Hydra (ex. `/api/category_types/3`).
* Payload accepte en POST /api/categories. `categoryTypes` est un tableau
* d'IRI Hydra (ex. `['/api/category_types/3', '/api/category_types/5']`).
*/
export interface CategoryCreateInput {
name: string
categoryType: string
categoryTypes: string[]
}
/**
@@ -67,5 +68,5 @@ export interface CategoryCreateInput {
*/
export interface CategoryUpdateInput {
name?: string
categoryType?: string
categoryTypes?: string[]
}