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"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ relatedModalTitle }}
|
||||
</h3>
|
||||
<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>
|
||||
<ModelTypesRelatedItemsModal
|
||||
:open="relatedModalOpen"
|
||||
:model-type="relatedType"
|
||||
@close="relatedModalOpen = false"
|
||||
@open-edit="openRelatedEdit"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -167,9 +120,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ModelTypesConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||
import {
|
||||
deleteModelType,
|
||||
@@ -233,7 +184,6 @@ let activeController: AbortController | null = null
|
||||
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { get } = useApi()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
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 relatedLoading = ref(false)
|
||||
const relatedError = ref<string | null>(null)
|
||||
const relatedItems = ref<RelatedEntry[]>([])
|
||||
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) => {
|
||||
if (category === 'COMPONENT') return '/component'
|
||||
if (category === 'PIECE') return '/pieces'
|
||||
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) => {
|
||||
relatedType.value = item
|
||||
relatedModalOpen.value = true
|
||||
void loadRelatedItems(item)
|
||||
}
|
||||
|
||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
const openRelatedEdit = (entry: { id: string }) => {
|
||||
const current = relatedType.value
|
||||
if (!current) return
|
||||
const basePath = resolveRelatedEditBasePath(current.category)
|
||||
@@ -531,10 +396,6 @@ const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
})
|
||||
}
|
||||
|
||||
const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false
|
||||
}
|
||||
|
||||
const conversionModalOpen = ref(false)
|
||||
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