Files
Inventory/app/components/PieceModelStructureEditor.vue
matthieu 7b3eb1c5fc refactor(catalog) : extract shared delete impact logic and cleanup dead code
Extract duplicated resolveDeleteImpact/buildDeleteMessage into shared utility,
remove redundant computed wrappers, fix indentation, and remove dead code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 01:35:21 +01:00

579 lines
17 KiB
Vue

<template>
<div class="space-y-6">
<section class="space-y-3">
<header>
<h3 class="text-sm font-semibold">
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
</p>
</header>
<p v-if="!products.length" class="text-xs text-gray-500">
Aucun produit défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(product, index) in products"
:key="product.uid"
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
>
<div class="flex items-start justify-between gap-3">
<div class="flex-1 space-y-3">
<div class="form-control">
<label class="label py-1">
<span class="label-text text-xs">Famille de produit</span>
</label>
<select
v-model="product.typeProductId"
class="select select-bordered select-xs"
:disabled="isProductLocked(product)"
@change="handleProductTypeSelect(product)"
>
<option value="">
Sélectionner une famille
</option>
<option
v-for="type in productTypeOptions"
:key="type.id"
:value="type.id"
>
{{ formatProductTypeOption(type) }}
</option>
</select>
</div>
</div>
<button
v-if="!isProductLocked(product)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeProduct(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ul>
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
<section class="space-y-3">
<h3 class="text-sm font-semibold">
Champs personnalisés
</h3>
<p v-if="!fields.length" class="text-xs text-gray-500">
Aucun champ personnalisé n'a encore été défini.
</p>
<ul v-else class="space-y-2" role="list">
<li
v-for="(field, index) in fields"
:key="field.uid"
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
:class="reorderClass(index)"
draggable="true"
@dragstart="onDragStart(index, $event)"
@dragenter="onDragEnter(index)"
@dragover.prevent="onDragEnter(index)"
@drop.prevent="onDrop(index)"
@dragend="onDragEnd"
>
<div class="flex items-start gap-3">
<button
type="button"
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
title="Réordonner"
draggable="false"
>
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
</button>
<div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
<option value="text">
Texte
</option>
<option value="number">
Nombre
</option>
<option value="select">
Liste
</option>
<option value="boolean">
Oui/Non
</option>
<option value="date">
Date
</option>
</select>
</div>
<div class="flex items-center gap-2 text-xs">
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
Obligatoire
</div>
<textarea
v-if="field.type === 'select'"
v-model="field.optionsText"
class="textarea textarea-bordered textarea-xs h-20"
placeholder="Option 1&#10;Option 2"
:disabled="isFieldLocked(field)"
/>
</div>
<button
v-if="!isFieldLocked(field)"
type="button"
class="btn btn-error btn-xs btn-square"
@click="removeField(index)"
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
<button
type="button"
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
disabled
>
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</li>
</ul>
<button type="button" class="btn btn-outline btn-xs" @click="addField">
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
Ajouter
</button>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash from '~icons/lucide/trash'
import type {
PieceModelCustomField,
PieceModelCustomFieldType,
PieceModelProduct,
PieceModelStructure,
PieceModelStructureEditorField,
} from '~/shared/types/inventory'
import { normalizePieceStructureForSave } from '~/shared/modelUtils'
import { useProductTypes } from '~/composables/useProductTypes'
defineOptions({ name: 'PieceModelStructureEditor' })
type EditorField = PieceModelStructureEditorField & { uid: string }
type EditorProduct = {
uid: string
typeProductId: string
typeProductLabel: string
familyCode: string
}
const props = defineProps<{
modelValue?: PieceModelStructure | null
restrictedMode?: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: PieceModelStructure): void
}>()
const { productTypes, loadProductTypes } = useProductTypes()
const ensureArray = <T,>(value: T[] | null | undefined): T[] =>
Array.isArray(value) ? value : []
const normalizeLineEndings = (value: string): string =>
value.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const safeClone = <T,>(value: T, fallback: T): T => {
try {
return JSON.parse(JSON.stringify(value ?? fallback)) as T
} catch {
return JSON.parse(JSON.stringify(fallback)) as T
}
}
const extractRest = (structure?: PieceModelStructure | null): Record<string, unknown> => {
if (!structure || typeof structure !== 'object') {
return {}
}
const entries = Object.entries(structure).filter(
([key]) => key !== 'customFields' && key !== 'products',
)
return safeClone(Object.fromEntries(entries), {})
}
let uidCounter = 0
const createUid = (scope: 'field' | 'product'): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
uidCounter += 1
return `piece-${scope}-${Date.now().toString(36)}-${uidCounter}`
}
const toEditorField = (
input: Partial<PieceModelStructureEditorField> | null | undefined,
index: number,
): EditorField => {
const baseType = typeof input?.type === 'string' && input.type ? input.type : 'text'
const optionsText = normalizeLineEndings(
typeof input?.optionsText === 'string'
? input.optionsText
: Array.isArray(input?.options)
? input.options.join('\n')
: '',
)
return {
uid: createUid('field'),
name: typeof input?.name === 'string' ? input.name : '',
type: baseType as PieceModelCustomFieldType,
required: Boolean(input?.required),
optionsText,
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
}
}
const hydrateFields = (structure?: PieceModelStructure | null): EditorField[] => {
const source = ensureArray(structure?.customFields)
return source
.map((field, index) => toEditorField(field, index))
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
.map((field, index) => ({ ...field, orderIndex: index }))
}
const toEditorProduct = (
input: Partial<PieceModelProduct> | null | undefined,
): EditorProduct => ({
uid: createUid('product'),
typeProductId: typeof input?.typeProductId === 'string' ? input.typeProductId : '',
typeProductLabel:
typeof input?.typeProductLabel === 'string' ? input.typeProductLabel : '',
familyCode: typeof input?.familyCode === 'string' ? input.familyCode : '',
})
const hydrateProducts = (structure?: PieceModelStructure | null): EditorProduct[] => {
const source = Array.isArray(structure?.products) ? structure?.products : []
return source.map((product) => toEditorProduct(product))
}
const productTypeOptions = computed(() => productTypes.value ?? [])
const productTypeMap = computed(() => {
const map = new Map<string, any>()
productTypeOptions.value.forEach((type: any) => {
if (type?.id) {
map.set(type.id, type)
}
})
return map
})
const formatProductTypeOption = (type: any) => {
if (!type) {
return ''
}
const parts: string[] = []
if (type.code) {
parts.push(type.code)
}
if (type.name) {
parts.push(type.name)
}
return parts.length ? parts.join(' • ') : type.id || ''
}
const updateProductTypeMetadata = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
}
const handleProductTypeSelect = (product: EditorProduct) => {
const option = product.typeProductId
? productTypeMap.value.get(product.typeProductId)
: null
product.typeProductLabel = option?.name ?? ''
if (option?.code) {
product.familyCode = option.code
}
}
const createEmptyProduct = (): EditorProduct => ({
uid: createUid('product'),
typeProductId: '',
typeProductLabel: '',
familyCode: '',
})
const addProduct = () => {
products.value.push(createEmptyProduct())
}
const removeProduct = (index: number) => {
products.value = products.value.filter((_, idx) => idx !== index)
}
const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
const isFieldLocked = (field: EditorField): boolean => {
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
}
const isProductLocked = (product: EditorProduct): boolean => {
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
}
const restrictedMode = computed(() => props.restrictedMode === true)
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
list.map((field, index) => ({
...field,
orderIndex: index,
}))
const normalizeProductEntry = (product: EditorProduct): PieceModelProduct | null => {
const typeProductId = typeof product.typeProductId === 'string' ? product.typeProductId.trim() : ''
const familyCode = typeof product.familyCode === 'string' ? product.familyCode.trim() : ''
if (!typeProductId && !familyCode) {
return null
}
const payload: PieceModelProduct = {}
if (typeProductId) {
payload.typeProductId = typeProductId
}
if (familyCode) {
payload.familyCode = familyCode
}
if (product.typeProductLabel) {
payload.typeProductLabel = product.typeProductLabel
}
return payload
}
const buildPayload = (
fieldsSource: EditorField[],
productsSource: EditorProduct[],
restSource: Record<string, unknown>,
): PieceModelStructure => {
const normalizedFields = fieldsSource
.map<PieceModelCustomField | null>((field, index) => {
const name = field.name.trim()
if (!name) {
return null
}
const type = (field.type || 'text') as PieceModelCustomFieldType
const required = Boolean(field.required)
const payload: PieceModelCustomField = {
name,
type,
required,
orderIndex: index,
}
if (type === 'select') {
const options = normalizeLineEndings(field.optionsText)
.split('\n')
.map((option) => option.trim())
.filter((option) => option.length > 0)
if (options.length > 0) {
payload.options = options
}
}
return payload
})
.filter((field): field is PieceModelCustomField => Boolean(field))
const normalizedProducts = productsSource
.map((product) => normalizeProductEntry(product))
.filter((product): product is PieceModelProduct => Boolean(product))
const draft: PieceModelStructure = {
...safeClone(restSource, {}),
products: normalizedProducts,
customFields: normalizedFields,
}
return normalizePieceStructureForSave(draft)
}
const serializeStructure = (structure?: PieceModelStructure | null): string => {
return JSON.stringify(normalizePieceStructureForSave(structure ?? { customFields: [] }))
}
let lastEmitted = serializeStructure(props.modelValue)
const emitUpdate = () => {
const payload = buildPayload(fields.value, products.value, restState.value)
const serialized = JSON.stringify(payload)
if (serialized !== lastEmitted) {
lastEmitted = serialized
emit('update:modelValue', payload)
}
}
watch(fields, emitUpdate, { deep: true })
watch(products, emitUpdate, { deep: true })
watch(productTypeOptions, () => {
products.value.forEach((product) => updateProductTypeMetadata(product))
})
watch(
() => props.modelValue,
(value) => {
const incomingSerialized = serializeStructure(value)
if (incomingSerialized === lastEmitted) {
return
}
restState.value = extractRest(value)
fields.value = hydrateFields(value)
products.value = hydrateProducts(value)
products.value.forEach((product) => updateProductTypeMetadata(product))
lastEmitted = incomingSerialized
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
initialProductUids.value = new Set(products.value.map(p => p.uid))
},
{ deep: true },
)
onMounted(async () => {
if (!productTypeOptions.value.length) {
await loadProductTypes()
}
products.value.forEach((product) => updateProductTypeMetadata(product))
})
const dragState = reactive({
draggingIndex: null as number | null,
dropTargetIndex: null as number | null,
})
const resetDragState = () => {
dragState.draggingIndex = null
dragState.dropTargetIndex = null
}
const reorderFields = (from: number, to: number) => {
if (from === to) {
resetDragState()
return
}
const list = fields.value.slice()
if (from < 0 || to < 0 || from >= list.length || to >= list.length) {
resetDragState()
return
}
const [moved] = list.splice(from, 1)
if (!moved) {
resetDragState()
return
}
list.splice(to, 0, moved)
fields.value = applyOrderIndex(list)
resetDragState()
}
const onDragStart = (index: number, event: DragEvent) => {
dragState.draggingIndex = index
dragState.dropTargetIndex = index
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
}
}
const onDragEnter = (index: number) => {
if (dragState.draggingIndex === null) {
return
}
dragState.dropTargetIndex = index
}
const onDrop = (index: number) => {
if (dragState.draggingIndex === null) {
resetDragState()
return
}
reorderFields(dragState.draggingIndex, index)
}
const onDragEnd = () => {
resetDragState()
}
const reorderClass = (index: number) => {
if (dragState.draggingIndex === index) {
return 'border-dashed border-primary bg-primary/5'
}
if (
dragState.draggingIndex !== null &&
dragState.dropTargetIndex === index &&
dragState.draggingIndex !== index
) {
return 'border-primary border-dashed bg-primary/10'
}
return ''
}
const createEmptyField = (orderIndex: number): EditorField => ({
uid: createUid('field'),
name: '',
type: 'text',
required: false,
optionsText: '',
orderIndex,
})
const addField = () => {
const next = fields.value.slice()
next.push(createEmptyField(next.length))
fields.value = applyOrderIndex(next)
}
const removeField = (index: number) => {
const next = fields.value.filter((_, i) => i !== index)
fields.value = applyOrderIndex(next)
}
</script>