refactor(frontend) : extract RelatedItemsModal from ManagementView
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -106,59 +106,12 @@
|
|||||||
@converted="onConverted"
|
@converted="onConverted"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<ModelTypesRelatedItemsModal
|
||||||
<div class="modal-box max-w-3xl">
|
:open="relatedModalOpen"
|
||||||
<h3 class="text-lg font-bold text-base-content">
|
:model-type="relatedType"
|
||||||
{{ relatedModalTitle }}
|
@close="relatedModalOpen = false"
|
||||||
</h3>
|
@open-edit="openRelatedEdit"
|
||||||
<p class="mt-1 text-sm text-base-content/70">
|
/>
|
||||||
{{ relatedModalSubtitle }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
|
||||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
|
||||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
|
||||||
Chargement des éléments liés…
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
|
||||||
{{ relatedError }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="relatedItems.length === 0"
|
|
||||||
class="px-4 py-6 text-sm text-base-content/60"
|
|
||||||
>
|
|
||||||
Aucun élément lié à cette catégorie.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
|
||||||
<li
|
|
||||||
v-for="entry in relatedItems"
|
|
||||||
:key="entry.id"
|
|
||||||
class="px-2 py-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
|
||||||
@click="openRelatedEdit(entry)"
|
|
||||||
>
|
|
||||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
|
||||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
|
||||||
Référence: {{ entry.reference }}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-action">
|
|
||||||
<button type="button" class="btn" @click="closeRelatedModal">
|
|
||||||
Fermer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -167,9 +120,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
|||||||
import { useHead, useRouter } from '#imports'
|
import { useHead, useRouter } from '#imports'
|
||||||
import DataTable from '~/components/common/DataTable.vue'
|
import DataTable from '~/components/common/DataTable.vue'
|
||||||
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
|
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||||
import { useApi } from '~/composables/useApi'
|
|
||||||
import { useUrlState } from '~/composables/useUrlState'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
|
||||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
@@ -233,7 +184,6 @@ let activeController: AbortController | null = null
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
const { get } = useApi()
|
|
||||||
const { canEdit } = usePermissions()
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const headingText = computed(() => props.heading)
|
const headingText = computed(() => props.heading)
|
||||||
@@ -422,106 +372,21 @@ const confirmDelete = async (item: ModelType) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type RelatedEntry = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
reference?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const relatedModalOpen = ref(false)
|
const relatedModalOpen = ref(false)
|
||||||
const relatedLoading = ref(false)
|
|
||||||
const relatedError = ref<string | null>(null)
|
|
||||||
const relatedItems = ref<RelatedEntry[]>([])
|
|
||||||
const relatedType = ref<ModelType | null>(null)
|
const relatedType = ref<ModelType | null>(null)
|
||||||
|
|
||||||
const relatedCategoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
|
|
||||||
COMPONENT: { plural: 'composants', singular: 'composant' },
|
|
||||||
PIECE: { plural: 'pièces', singular: 'pièce' },
|
|
||||||
PRODUCT: { plural: 'produits', singular: 'produit' },
|
|
||||||
}
|
|
||||||
|
|
||||||
const relatedModalTitle = computed(() => {
|
|
||||||
const current = relatedType.value
|
|
||||||
if (!current) return 'Éléments liés'
|
|
||||||
return `Éléments liés à « ${current.name} »`
|
|
||||||
})
|
|
||||||
|
|
||||||
const relatedModalSubtitle = computed(() => {
|
|
||||||
const current = relatedType.value
|
|
||||||
if (!current) return ''
|
|
||||||
const labels = relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT
|
|
||||||
const count = relatedItems.value.length
|
|
||||||
if (relatedLoading.value) return `Chargement des ${labels.plural}…`
|
|
||||||
if (count === 0) return `Aucun ${labels.singular} lié.`
|
|
||||||
if (count === 1) return `1 ${labels.singular} lié.`
|
|
||||||
return `${count} ${labels.plural} liés.`
|
|
||||||
})
|
|
||||||
|
|
||||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`
|
|
||||||
|
|
||||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
|
||||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
|
||||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
|
||||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||||
if (category === 'COMPONENT') return '/component'
|
if (category === 'COMPONENT') return '/component'
|
||||||
if (category === 'PIECE') return '/pieces'
|
if (category === 'PIECE') return '/pieces'
|
||||||
return '/product'
|
return '/product'
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
|
||||||
if (!item || typeof item !== 'object') return null
|
|
||||||
const record = item as Record<string, unknown>
|
|
||||||
if (typeof record.id !== 'string') return null
|
|
||||||
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
|
||||||
const reference
|
|
||||||
= typeof record.reference === 'string' && record.reference.trim()
|
|
||||||
? record.reference
|
|
||||||
: typeof record.code === 'string' && record.code.trim()
|
|
||||||
? record.code
|
|
||||||
: null
|
|
||||||
return { id: record.id, name, reference }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadRelatedItems = async (item: ModelType) => {
|
|
||||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category)
|
|
||||||
const params = new URLSearchParams()
|
|
||||||
params.set('itemsPerPage', '200')
|
|
||||||
params.set(filterKey, buildModelTypeIri(item.id))
|
|
||||||
params.set('order[name]', 'asc')
|
|
||||||
|
|
||||||
relatedLoading.value = true
|
|
||||||
relatedError.value = null
|
|
||||||
relatedItems.value = []
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await get(`${endpoint}?${params.toString()}`)
|
|
||||||
if (!result.success) {
|
|
||||||
relatedError.value = result.error ?? 'Impossible de charger les éléments liés.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const collection = extractCollection(result.data)
|
|
||||||
relatedItems.value = collection
|
|
||||||
.map(mapRelatedEntry)
|
|
||||||
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
relatedError.value = extractErrorMessage(error)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
relatedLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openRelatedModal = (item: ModelType) => {
|
const openRelatedModal = (item: ModelType) => {
|
||||||
relatedType.value = item
|
relatedType.value = item
|
||||||
relatedModalOpen.value = true
|
relatedModalOpen.value = true
|
||||||
void loadRelatedItems(item)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
const openRelatedEdit = (entry: { id: string }) => {
|
||||||
const current = relatedType.value
|
const current = relatedType.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
const basePath = resolveRelatedEditBasePath(current.category)
|
const basePath = resolveRelatedEditBasePath(current.category)
|
||||||
@@ -531,10 +396,6 @@ const openRelatedEdit = (entry: RelatedEntry) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeRelatedModal = () => {
|
|
||||||
relatedModalOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversionModalOpen = ref(false)
|
const conversionModalOpen = ref(false)
|
||||||
const conversionTarget = ref<ModelType | null>(null)
|
const conversionTarget = ref<ModelType | null>(null)
|
||||||
|
|
||||||
|
|||||||
182
app/components/model-types/RelatedItemsModal.vue
Normal file
182
app/components/model-types/RelatedItemsModal.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||||
|
<div class="modal-box max-w-3xl">
|
||||||
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
|
{{ modalTitle }}
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
{{ modalSubtitle }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||||
|
<div v-if="loading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||||
|
Chargement des éléments liés…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="px-4 py-6 text-sm text-error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="items.length === 0"
|
||||||
|
class="px-4 py-6 text-sm text-base-content/60"
|
||||||
|
>
|
||||||
|
Aucun élément lié à cette catégorie.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||||
|
<li
|
||||||
|
v-for="entry in items"
|
||||||
|
:key="entry.id"
|
||||||
|
class="px-2 py-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||||
|
@click="onOpenEdit(entry)"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||||
|
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||||
|
Référence: {{ entry.reference }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||||
|
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
||||||
|
|
||||||
|
type RelatedEntry = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
reference?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
modelType: ModelType | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
'open-edit': [entry: RelatedEntry]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const items = ref<RelatedEntry[]>([])
|
||||||
|
|
||||||
|
const categoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
|
||||||
|
COMPONENT: { plural: 'composants', singular: 'composant' },
|
||||||
|
PIECE: { plural: 'pièces', singular: 'pièce' },
|
||||||
|
PRODUCT: { plural: 'produits', singular: 'produit' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
if (!props.modelType) return 'Éléments liés'
|
||||||
|
return `Éléments liés à « ${props.modelType.name} »`
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalSubtitle = computed(() => {
|
||||||
|
if (!props.modelType) return ''
|
||||||
|
const labels = categoryLabels[props.modelType.category] ?? categoryLabels.COMPONENT
|
||||||
|
const count = items.value.length
|
||||||
|
if (loading.value) return `Chargement des ${labels.plural}…`
|
||||||
|
if (count === 0) return `Aucun ${labels.singular} lié.`
|
||||||
|
if (count === 1) return `1 ${labels.singular} lié.`
|
||||||
|
return `${count} ${labels.plural} liés.`
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||||
|
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||||
|
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||||
|
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||||
|
if (!item || typeof item !== 'object') return null
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
if (typeof record.id !== 'string') return null
|
||||||
|
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
||||||
|
const reference
|
||||||
|
= typeof record.reference === 'string' && record.reference.trim()
|
||||||
|
? record.reference
|
||||||
|
: typeof record.code === 'string' && record.code.trim()
|
||||||
|
? record.code
|
||||||
|
: null
|
||||||
|
return { id: record.id, name, reference }
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadRelatedItems = async (modelType: ModelType) => {
|
||||||
|
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('itemsPerPage', '200')
|
||||||
|
params.set(filterKey, `/api/model_types/${modelType.id}`)
|
||||||
|
params.set('order[name]', 'asc')
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
items.value = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await get(`${endpoint}?${params.toString()}`)
|
||||||
|
if (!result.success) {
|
||||||
|
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const collection = extractCollection(result.data)
|
||||||
|
items.value = collection
|
||||||
|
.map(mapRelatedEntry)
|
||||||
|
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
let raw: string | null = null
|
||||||
|
if (err && typeof err === 'object') {
|
||||||
|
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
|
||||||
|
if (e.data) {
|
||||||
|
const data = e.data
|
||||||
|
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 (typeof data.error === 'string') raw = data.error
|
||||||
|
}
|
||||||
|
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
|
||||||
|
if (!raw && typeof e.message === 'string') raw = e.message
|
||||||
|
}
|
||||||
|
error.value = humanizeError(raw)
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpenEdit = (entry: RelatedEntry) => {
|
||||||
|
emit('open-edit', entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(isOpen) => {
|
||||||
|
if (isOpen && props.modelType) {
|
||||||
|
void loadRelatedItems(props.modelType)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user