Compare commits

..

11 Commits

Author SHA1 Message Date
gitea-actions d1b170d87f chore : bump version to v1.9.39
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 1m20s
2026-05-27 13:45:07 +00:00
Matthieu 0fc9daa974 feat(machines) : unicité du nom de machine par site
Auto Tag Develop / tag (push) Successful in 13s
Le nom d'une machine n'est plus unique globalement mais par site :
deux machines peuvent porter le même nom sur des sites différents,
mais le doublon reste interdit sur un même site.

- Machine : contrainte composite (name, siteId) + UniqueEntity (name, site)
- UniqueConstraintSubscriber : message explicite pour uniq_machine_name_site
- Migration : drop index global sur name + create unique index (name, siteid)
- Front : message d'erreur inline explicite à la création (page + modale)
- Tests : 4 scénarios (sites différents / même site / renommage / déplacement)
2026-05-27 15:37:38 +02:00
gitea-actions 104942a52b chore : bump version to v1.9.38
Build & Push Docker Image / build (push) Successful in 2m53s
Auto Tag Develop / tag (push) Successful in 9s
2026-05-21 14:28:44 +00:00
Matthieu c65757ee24 feat(vue-ensemble) : tri alphabétique des machines par défaut + select de tri (nom/date) + harmonisation tailles des champs de filtre
Auto Tag Develop / tag (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 16:23:40 +02:00
Matthieu 6e105fd070 chore : bump version to v1.9.37
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
Matthieu d3f269452c chore : bump version to v1.9.36
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-13 10:46:51 +02:00
gitea-actions b3fa927e77 chore : bump version to v1.9.35
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 08:44:31 +00:00
Matthieu f71f4c68da feat(fournisseurs) : pagination serveur + search multi-champs (name/email/telephone) + filtre catégorie + tri
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)

Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:44:20 +02:00
gitea-actions 905d5c0957 chore : bump version to v1.9.34
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-05-13 08:23:57 +00:00
Matthieu 03a5d05a2c feat(machine) : champs perso machine en badges plus gros dans entete composants et pieces
Auto Tag Develop / tag (push) Has been cancelled
Affiche les champs perso machine entre Row 1 (titre/prix) et Row 2 (fournisseur/catalogue) de l'entete ComponentItem et PieceItem.
Badges plus gros (text-sm), visibles en lecture ET en edition. Edition complete reste dans la section depliee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:47 +02:00
15 changed files with 476 additions and 71 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.33' app.version: '1.9.39'
+17 -12
View File
@@ -69,9 +69,25 @@
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span> <span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div> </div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags --> <!-- Row 2: Metadata tags -->
<div <div
v-if="componentConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)" v-if="componentConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5" class="flex flex-wrap items-center gap-1.5"
> >
<span <span
@@ -85,17 +101,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div> </div>
</div> </div>
+17 -12
View File
@@ -71,9 +71,25 @@
<span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span> <span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
</div> </div>
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags --> <!-- Row 2: Metadata tags -->
<div <div
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName || (!isEditMode && visibleContextFieldTags.length)" v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5" class="flex flex-wrap items-center gap-1.5"
> >
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded"> <span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
@@ -90,17 +106,6 @@
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
<!-- Context field tags (consultation only) -->
<template v-if="!isEditMode">
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="text-[0.65rem] font-semibold px-1.5 py-0.5 rounded"
:class="contextFieldBadgeClass(field)"
>
{{ field.name }} : {{ field.value }}
</span>
</template>
</div> </div>
</div> </div>
@@ -5,6 +5,19 @@
Ajouter une nouvelle machine Ajouter une nouvelle machine
</h3> </h3>
<form @submit.prevent="handleSubmit"> <form @submit.prevent="handleSubmit">
<div v-if="errorMessage" class="alert alert-error mb-4" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ errorMessage }}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -78,6 +91,7 @@ const props = defineProps<{
sites: Array<{ id: string, name: string }> sites: Array<{ id: string, name: string }>
disabled: boolean disabled: boolean
preselectedSiteId?: string preselectedSiteId?: string
errorMessage?: string | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+55 -3
View File
@@ -1,7 +1,7 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface ConstructeurTelephone { export interface ConstructeurTelephone {
'@id'?: string '@id'?: string
@@ -33,6 +33,24 @@ interface ConstructeurResult {
error?: string error?: string
} }
export interface ConstructeurPageOptions {
page?: number
itemsPerPage?: number
search?: string
categoryId?: string
orderField?: 'name' | 'email' | 'createdAt'
orderDirection?: 'asc' | 'desc'
}
export interface ConstructeurPageResult {
success: boolean
items: Constructeur[]
totalItems: number
totalPages: number
currentPage: number
error?: string
}
const constructeurs = ref<Constructeur[]>([]) const constructeurs = ref<Constructeur[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
@@ -83,8 +101,10 @@ export function useConstructeurs() {
} }
loading.value = true loading.value = true
try { try {
const query = search ? `?search=${encodeURIComponent(search)}` : '' const params = new URLSearchParams()
const result = await get(`/constructeurs${query}`) params.set('itemsPerPage', '2000')
if (search) params.set('search', search)
const result = await get(`/constructeurs?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items) constructeurs.value = uniqueConstructeurs(items)
@@ -104,6 +124,37 @@ export function useConstructeurs() {
return loadConstructeurs(search) return loadConstructeurs(search)
} }
const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
const page = Math.max(1, opts.page ?? 1)
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
loading.value = true
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('itemsPerPage', String(itemsPerPage))
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
if (opts.categoryId) params.set('categories.id', opts.categoryId)
if (opts.orderField) {
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
}
const result = await get(`/constructeurs?${params.toString()}`)
if (!result.success) {
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
}
const items = extractCollection<Constructeur>(result.data)
const totalItems = extractTotal(result.data, items.length)
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
upsertConstructeurs(items)
return { success: true, items, totalItems, totalPages, currentPage: page }
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement de la page fournisseurs:', error)
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
} finally {
loading.value = false
}
}
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => { const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
@@ -227,6 +278,7 @@ export function useConstructeurs() {
loading, loading,
loadConstructeurs, loadConstructeurs,
searchConstructeurs, searchConstructeurs,
fetchConstructeursPage,
createConstructeur, createConstructeur,
updateConstructeur, updateConstructeur,
deleteConstructeur, deleteConstructeur,
@@ -8,7 +8,6 @@
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
export function useMachineCreatePage() { export function useMachineCreatePage() {
@@ -18,7 +17,6 @@ export function useMachineCreatePage() {
const { machines, loadMachines, createMachine, cloneMachine } = useMachines() const { machines, loadMachines, createMachine, cloneMachine } = useMachines()
const { sites, loadSites } = useSites() const { sites, loadSites } = useSites()
const toast = useToast()
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Local state // Local state
@@ -27,6 +25,9 @@ export function useMachineCreatePage() {
const submitting = ref(false) const submitting = ref(false)
const loading = ref(true) const loading = ref(true)
/** Persistent error shown inline in the form (e.g. duplicate name on the same site). */
const createError = ref<string | null>(null)
const newMachine = reactive({ const newMachine = reactive({
name: '', name: '',
siteId: '', siteId: '',
@@ -41,8 +42,10 @@ export function useMachineCreatePage() {
const finalizeMachineCreation = async () => { const finalizeMachineCreation = async () => {
if (submitting.value) return if (submitting.value) return
createError.value = null
if (!newMachine.name?.trim()) { if (!newMachine.name?.trim()) {
toast.showError('Merci de renseigner un nom pour la machine') createError.value = 'Merci de renseigner un nom pour la machine.'
return return
} }
@@ -80,10 +83,10 @@ export function useMachineCreatePage() {
await navigateTo('/machines') await navigateTo('/machines')
} }
} else if (result.error) { } else if (result.error) {
toast.showError(`Impossible de créer la machine : ${humanizeError(result.error)}`) createError.value = humanizeError(result.error)
} }
} catch (error: any) { } catch (error: any) {
toast.showError(`Impossible de créer la machine : ${humanizeError(error.message)}`) createError.value = humanizeError(error.message)
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -116,6 +119,7 @@ export function useMachineCreatePage() {
machines, machines,
submitting, submitting,
loading, loading,
createError,
// Actions // Actions
finalizeMachineCreation, finalizeMachineCreation,
+82 -27
View File
@@ -19,13 +19,17 @@
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DataTable <DataTable
:columns="columns" :columns="columns"
:rows="filteredConstructeurs" :rows="pageItems"
:loading="loading" :loading="loading"
:sort="currentSort" :sort="currentSort"
:show-counter="false" :pagination="paginationState"
:show-counter="true"
:show-per-page="true"
empty-message="Aucun fournisseur trouvé." empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé." no-results-message="Aucun fournisseur trouvé."
@sort="handleSort" @sort="handleSort"
@update:current-page="onPageChange"
@update:per-page="onPerPageChange"
> >
<template #toolbar> <template #toolbar>
<div class="flex flex-col sm:flex-row gap-3 w-full"> <div class="flex flex-col sm:flex-row gap-3 w-full">
@@ -204,7 +208,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue' import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
@@ -229,10 +233,17 @@ interface ConstructeurFormState {
const api = useApi() const api = useApi()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
const { categories: allCategories, loadCategories } = useConstructeurCategories() const { categories: allCategories, loadCategories } = useConstructeurCategories()
const { showError } = useToast() const { showError } = useToast()
const pageItems = ref<typeof constructeurs.value>([])
const totalItems = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const perPage = ref(30)
const perPageOptions = [15, 30, 50, 100]
const columns = [ const columns = [
{ key: 'name', label: 'Nom', sortable: true }, { key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true }, { key: 'email', label: 'Email', sortable: true },
@@ -261,6 +272,44 @@ const handleSort = (sort) => {
sortDir.value = sort.direction sortDir.value = sort.direction
} }
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: totalItems.value,
pageItems: pageItems.value.length,
perPage: perPage.value,
perPageOptions,
}))
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
const loadPage = async () => {
const orderField = SORTABLE_FIELDS.has(sortKey.value)
? (sortKey.value as 'name' | 'email' | 'createdAt')
: 'name'
const result = await fetchConstructeursPage({
page: currentPage.value,
itemsPerPage: perPage.value,
search: searchTerm.value,
categoryId: selectedCategoryId.value || undefined,
orderField,
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
})
if (!result.success) {
if (result.error) showError(result.error)
pageItems.value = []
totalItems.value = 0
totalPages.value = 0
return
}
pageItems.value = result.items
totalItems.value = result.totalItems
totalPages.value = result.totalPages
if (currentPage.value > result.totalPages && result.totalPages > 0) {
currentPage.value = result.totalPages
}
}
const modalOpen = ref(false) const modalOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const editingConstructeur = ref<Record<string, any> | null>(null) const editingConstructeur = ref<Record<string, any> | null>(null)
@@ -268,29 +317,31 @@ const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], c
const rowPhones = constructeurPhones const rowPhones = constructeurPhones
const filteredConstructeurs = computed(() => { const debouncedSearch = debounce(() => {
const key = sortKey.value currentPage.value = 1
const dir = sortDir.value === 'desc' ? -1 : 1 loadPage()
let sorted = [...constructeurs.value].sort((a, b) => { }, 300)
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime()) watch(selectedCategoryId, () => {
} currentPage.value = 1
return dir * (a[key] || '').localeCompare(b[key] || '') loadPage()
})
if (selectedCategoryId.value) {
sorted = sorted.filter(item => (item.categories || []).some(cat => cat.id === selectedCategoryId.value))
}
if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase()
return sorted.filter((item) => {
const haystack = [item.name, item.email, ...rowPhones(item).map(t => t.numero)]
return haystack.some(value => value && String(value).toLowerCase().includes(term))
})
}) })
const debouncedSearch = debounce(async () => { watch([sortKey, sortDir], () => {
await searchConstructeurs(searchTerm.value) currentPage.value = 1
}, 300) loadPage()
})
const onPageChange = (page: number) => {
currentPage.value = page
loadPage()
}
const onPerPageChange = (value: number) => {
perPage.value = value
currentPage.value = 1
loadPage()
}
const formatDate = formatFrenchDate const formatDate = formatFrenchDate
@@ -386,7 +437,7 @@ const saveConstructeur = async () => {
saving.value = false saving.value = false
if (result.success) { if (result.success) {
closeModal() closeModal()
await searchConstructeurs(searchTerm.value) await loadPage()
} }
} }
@@ -397,6 +448,10 @@ const confirmDelete = async (constructeur) => {
const result = await deleteConstructeur(constructeur.id) const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) { if (!result.success && result.error) {
showError(result.error) showError(result.error)
return
}
if (result.success) {
await loadPage()
} }
} }
@@ -408,7 +463,7 @@ const loadStats = async () => {
} }
onMounted(() => { onMounted(() => {
loadConstructeurs() loadPage()
loadCategories() loadCategories()
loadStats() loadStats()
}) })
+65 -7
View File
@@ -58,7 +58,26 @@
</option> </option>
</select> </select>
</div> </div>
<div class="form-control"> <div class="form-control md:w-52">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Trier par</span>
</label>
<select v-model="sortOrder" class="select select-bordered w-full">
<option value="name-asc">
Nom (A → Z)
</option>
<option value="name-desc">
Nom (Z → A)
</option>
<option value="date-desc">
Plus récentes
</option>
<option value="date-asc">
Plus anciennes
</option>
</select>
</div>
<div class="form-control md:w-80">
<label class="label"> <label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
</label> </label>
@@ -66,13 +85,13 @@
<input <input
v-model="dateFrom" v-model="dateFrom"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered w-full"
> >
<span class="text-xs text-base-content/50">à</span> <span class="text-xs text-base-content/50">à</span>
<input <input
v-model="dateTo" v-model="dateTo"
type="date" type="date"
class="input input-bordered input-sm" class="input input-bordered w-full"
> >
</div> </div>
</div> </div>
@@ -97,7 +116,7 @@
<button class="btn btn-primary btn-sm" @click="showAddSiteModal = true"> <button class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site Ajouter un site
</button> </button>
<button class="btn btn-ghost btn-sm" @click="showAddMachineModal = true"> <button class="btn btn-ghost btn-sm" @click="openAddMachineModal">
Ajouter une machine Ajouter une machine
</button> </button>
</div> </div>
@@ -263,7 +282,8 @@
:sites="sites" :sites="sites"
:disabled="!canEdit" :disabled="!canEdit"
:preselected-site-id="preselectedSiteId" :preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false" :error-message="addMachineError"
@close="closeAddMachineModal"
@create="handleCreateMachine" @create="handleCreateMachine"
/> />
</main> </main>
@@ -293,8 +313,10 @@ const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data // Data
const showAddSiteModal = ref(false) const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false) const showAddMachineModal = ref(false)
const addMachineError = ref(null)
const searchTerm = ref('') const searchTerm = ref('')
const selectedSiteFilter = ref('') const selectedSiteFilter = ref('')
const sortOrder = ref('name-asc')
const dateFrom = ref('') const dateFrom = ref('')
const dateTo = ref('') const dateTo = ref('')
const collapsedSites = ref([]) const collapsedSites = ref([])
@@ -318,10 +340,33 @@ const machinesBySiteId = computed(() => {
return map return map
}) })
const sortMachines = (machineList) => {
const list = [...machineList]
switch (sortOrder.value) {
case 'name-desc':
return list.sort((a, b) =>
(b.name || '').localeCompare(a.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
case 'date-desc':
return list.sort((a, b) =>
new Date(b.createdAt || 0) - new Date(a.createdAt || 0)
)
case 'date-asc':
return list.sort((a, b) =>
new Date(a.createdAt || 0) - new Date(b.createdAt || 0)
)
case 'name-asc':
default:
return list.sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr', { sensitivity: 'base', numeric: true })
)
}
}
const sitesWithMachines = computed(() => { const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({ return sites.value.map((site) => ({
...site, ...site,
machines: machinesBySiteId.value.get(site.id) || [] machines: sortMachines(machinesBySiteId.value.get(site.id) || [])
})) }))
}) })
@@ -406,11 +451,14 @@ const handleCreateSite = async (data) => {
} }
const handleCreateMachine = async (data) => { const handleCreateMachine = async (data) => {
addMachineError.value = null
const result = await createMachine(data) const result = await createMachine(data)
if (result.success) { if (result.success) {
showAddMachineModal.value = false showAddMachineModal.value = false
await loadMachines() await loadMachines()
} else if (result.error) {
addMachineError.value = humanizeError(result.error)
} }
} }
@@ -455,9 +503,19 @@ const confirmDeleteMachine = async (machine) => {
} }
} }
const openAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = true
}
const closeAddMachineModal = () => {
addMachineError.value = null
showAddMachineModal.value = false
}
const addMachineToSite = (site) => { const addMachineToSite = (site) => {
preselectedSiteId.value = site.id preselectedSiteId.value = site.id
showAddMachineModal.value = true openAddMachineModal()
} }
// Lifecycle // Lifecycle
+13
View File
@@ -20,6 +20,19 @@
</div> </div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation"> <form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div v-if="c.createError" class="alert alert-error" role="alert">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</svg>
<span>{{ c.createError }}</span>
</div>
<div class="card bg-base-100 shadow-sm"> <div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<!-- Basic fields --> <!-- Basic fields -->
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260527140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Machine name uniqueness is now scoped per site: drop global unique index on machines(name), add composite unique index on (name, siteid)';
}
public function up(Schema $schema): void
{
// Drop the global unique index/constraint on machines(name).
// Doctrine-generated name (CRC32 of table+column): uniq_f1ce8ded5e237e06.
// It may exist either as a constraint or as a bare index depending on origin,
// so we drop defensively in both forms.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS uniq_f1ce8ded5e237e06');
$this->addSql('DROP INDEX IF EXISTS uniq_f1ce8ded5e237e06');
// Defensive fallbacks for other possible legacy names of the global unique index on name.
$this->addSql('ALTER TABLE machines DROP CONSTRAINT IF EXISTS machines_name_key');
$this->addSql('DROP INDEX IF EXISTS machines_name_key');
// New uniqueness scope: a machine name is unique within a given site only.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_machine_name_site ON machines (name, siteid)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX IF EXISTS uniq_machine_name_site');
// Best-effort restore of the global unique index on machines(name).
// WARNING: this will fail if duplicate names now exist across sites (which the
// per-site scope allowed). Resolve duplicates manually before rolling back.
$this->addSql('CREATE UNIQUE INDEX IF NOT EXISTS uniq_f1ce8ded5e237e06 ON machines (name)');
}
}
+8 -1
View File
@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\ConstructeurSearchFilter;
use App\Repository\ConstructeurRepository; use App\Repository\ConstructeurRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -26,6 +30,9 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)] #[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')] #[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(ConstructeurSearchFilter::class)]
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
#[ApiResource( #[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.', description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [ operations: [
@@ -37,7 +44,7 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
], ],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200, paginationMaximumItemsPerPage: 2000,
normalizationContext: ['groups' => ['constructeur:read']], normalizationContext: ['groups' => ['constructeur:read']],
denormalizationContext: ['groups' => ['constructeur:write']] denormalizationContext: ['groups' => ['constructeur:write']]
)] )]
+3 -1
View File
@@ -24,8 +24,10 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: MachineRepository::class)] #[ORM\Entity(repositoryClass: MachineRepository::class)]
#[ORM\Table(name: 'machines')] #[ORM\Table(name: 'machines')]
#[ORM\UniqueConstraint(name: 'uniq_machine_name_site', columns: ['name', 'siteId'])]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)] #[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
#[UniqueEntity(fields: ['name', 'site'], message: 'Une machine avec ce nom existe déjà sur ce site.')]
#[ApiResource( #[ApiResource(
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.', description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
operations: [ operations: [
@@ -45,7 +47,7 @@ class Machine
#[Groups(['document:list'])] #[Groups(['document:list'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255)]
#[Groups(['document:list'])] #[Groups(['document:list'])]
private string $name; private string $name;
@@ -31,6 +31,7 @@ final class UniqueConstraintSubscriber implements EventSubscriberInterface
$constraint = $this->detectConstraintName($exception); $constraint = $this->detectConstraintName($exception);
$error = match ($constraint) { $error = match ($constraint) {
'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.', 'unique_category_name' => 'Un élément avec ce nom existe déjà dans cette catégorie.',
'uniq_machine_name_site' => 'Une machine avec ce nom existe déjà sur ce site.',
default => 'Un élément avec cette valeur existe déjà.', default => 'Un élément avec cette valeur existe déjà.',
}; };
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder;
/**
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
* Param query : ?search=...
*/
final class ConstructeurSearchFilter extends AbstractFilter
{
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
'openapi' => [
'allowEmptyValue' => true,
],
],
];
}
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias,
$paramName,
$phoneSubQuery,
))
->setParameter($paramName, $likePattern)
;
}
}
+82
View File
@@ -134,6 +134,88 @@ class MachineTest extends AbstractApiTestCase
$this->assertResponseStatusCodeSame(422); $this->assertResponseStatusCodeSame(422);
} }
public function testSameNameOnDifferentSitesIsAllowed(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $siteB->getId()),
],
]);
$this->assertResponseStatusCodeSame(201);
$this->assertJsonContains(['name' => 'Pompe']);
}
public function testSameNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$client = $this->createGestionnaireClient();
$client->request('POST', '/api/machines', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Pompe',
'site' => self::iri('sites', $site->getId()),
],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testRenameToExistingNameOnSameSiteIsRejected(): void
{
$site = $this->createSite('Usine');
$this->createMachine('Pompe', $site);
$other = $this->createMachine('Moteur', $site);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $other->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['name' => 'Pompe'],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testMoveToSiteWhereNameExistsIsRejected(): void
{
$siteA = $this->createSite('Usine A');
$siteB = $this->createSite('Usine B');
$this->createMachine('Pompe', $siteB);
$machine = $this->createMachine('Pompe', $siteA);
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('machines', $machine->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => self::iri('sites', $siteB->getId())],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertJsonContains([
'violations' => [
['message' => 'Une machine avec ce nom existe déjà sur ce site.'],
],
]);
}
public function testGetStructureEndpoint(): void public function testGetStructureEndpoint(): void
{ {
$machine = $this->createMachine('Machine structure'); $machine = $this->createMachine('Machine structure');