refactor : merge Inventory_frontend submodule into frontend/ directory
Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
236
frontend/app/components/machine/AddEntityToMachineModal.vue
Normal file
236
frontend/app/components/machine/AddEntityToMachineModal.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box max-w-xl w-full" style="overflow: visible">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
|
||||
@click="handleClose"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h3 class="font-bold text-lg mb-6">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- Step 1: Choose category -->
|
||||
<div class="form-control mb-5" style="position: relative; z-index: 20">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">Catégorie</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedTypeId"
|
||||
:options="types"
|
||||
:loading="loadingTypes"
|
||||
:max-visible="8"
|
||||
placeholder="Rechercher une catégorie..."
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="(t: any) => t.name"
|
||||
:option-description="(t: any) => t.code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Choose entity (visible only after category selected) -->
|
||||
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">{{ entityLabel }}</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedEntityId"
|
||||
:options="entities"
|
||||
:loading="loadingEntities"
|
||||
:max-visible="8"
|
||||
:placeholder="`Rechercher ${entityLabelLower}...`"
|
||||
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||
:option-label="entityOptionLabel"
|
||||
:option-description="entityOptionDescription"
|
||||
server-search
|
||||
@search="handleEntitySearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary of selection -->
|
||||
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
|
||||
Réf : {{ selectedEntitySummary.reference }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
|
||||
<button type="button" class="btn btn-ghost" @click="handleClose">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
|
||||
type EntityKind = 'component' | 'piece' | 'product'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
const selectedEntityId = ref('')
|
||||
const loadingEntities = ref(false)
|
||||
const entities = ref<any[]>([])
|
||||
|
||||
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
|
||||
const { loadComposants } = useComposants()
|
||||
const { loadPieces } = usePieces()
|
||||
const { loadProducts } = useProducts()
|
||||
|
||||
const title = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Ajouter un composant',
|
||||
piece: 'Ajouter une pièce',
|
||||
product: 'Ajouter un produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabel = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Composant',
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
|
||||
|
||||
const types = computed(() => {
|
||||
if (props.entityKind === 'component') return componentTypes.value
|
||||
if (props.entityKind === 'piece') return pieceTypes.value
|
||||
return productTypes.value
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => {
|
||||
if (props.entityKind === 'component') return loadingComponentTypes.value
|
||||
if (props.entityKind === 'piece') return loadingPieceTypes.value
|
||||
return loadingProductTypes.value
|
||||
})
|
||||
|
||||
const selectedTypeName = computed(() => {
|
||||
if (!selectedTypeId.value) return ''
|
||||
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
|
||||
return found?.name || ''
|
||||
})
|
||||
|
||||
const entityOptionLabel = (e: any) => {
|
||||
const name = e.name || '(sans nom)'
|
||||
return e.reference ? `${name} — ${e.reference}` : name
|
||||
}
|
||||
const entityOptionDescription = (e: any) => e.reference || ''
|
||||
|
||||
const selectedEntitySummary = computed(() => {
|
||||
if (!selectedEntityId.value || !entities.value.length) return null
|
||||
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
|
||||
if (!found) return null
|
||||
return { name: found.name || '(sans nom)', reference: found.reference || null }
|
||||
})
|
||||
|
||||
// Load types when modal opens
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
watch(selectedTypeId, async () => {
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
|
||||
if (!selectedTypeName.value) return
|
||||
|
||||
loadingEntities.value = true
|
||||
try {
|
||||
if (props.entityKind === 'component') {
|
||||
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else if (props.entityKind === 'piece') {
|
||||
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else {
|
||||
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
}
|
||||
} finally {
|
||||
loadingEntities.value = false
|
||||
}
|
||||
})
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleEntitySearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(async () => {
|
||||
if (!selectedTypeName.value) return
|
||||
loadingEntities.value = true
|
||||
try {
|
||||
if (props.entityKind === 'component') {
|
||||
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else if (props.entityKind === 'piece') {
|
||||
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else {
|
||||
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
}
|
||||
} finally {
|
||||
loadingEntities.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
selectedTypeId.value = ''
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
}
|
||||
</script>
|
||||
72
frontend/app/components/machine/MachineComponentsCard.vue
Normal file
72
frontend/app/components/machine/MachineComponentsCard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Composants</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucun composant associé à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="component in components" :key="component.id">
|
||||
<ComponentHierarchy
|
||||
:components="[component]"
|
||||
:is-edit-mode="false"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-component', component.linkId || component.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-component')"
|
||||
>
|
||||
Ajouter un composant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
defineProps<{
|
||||
components: any[]
|
||||
isEditMode: boolean
|
||||
collapsed: boolean
|
||||
collapseToggleToken: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-component': [component: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
112
frontend/app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
112
frontend/app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Définitions des champs personnalisés
|
||||
</h3>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
|
||||
</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-sm"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-sm">
|
||||
<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">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-sm h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="$emit('remove-field', index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('add-field')">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MachineCustomFieldEditorField } from '~/composables/useMachineCustomFieldDefs'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
|
||||
defineProps<{
|
||||
fields: MachineCustomFieldEditorField[]
|
||||
saving: boolean
|
||||
reorderClass: (index: number) => string
|
||||
onDragStart: (index: number, event: DragEvent) => void
|
||||
onDragEnter: (index: number) => void
|
||||
onDrop: (index: number) => void
|
||||
onDragEnd: () => void
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-field': []
|
||||
'remove-field': [index: number]
|
||||
}>()
|
||||
</script>
|
||||
221
frontend/app/components/machine/MachineCustomFieldsCard.vue
Normal file
221
frontend/app/components/machine/MachineCustomFieldsCard.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Champs personnalisés</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
Champs personnalisés propres à cette machine.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="visibleCustomFields.length" class="badge badge-outline">
|
||||
{{ visibleCustomFields.length }} champ{{ visibleCustomFields.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- View mode: display values -->
|
||||
<template v-if="!isEditMode">
|
||||
<div v-if="visibleCustomFields.length" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini pour cette machine.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Edit mode: definition management + value editing -->
|
||||
<template v-else>
|
||||
<p v-if="!customFields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini.
|
||||
</p>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in customFields"
|
||||
:key="field.id || field.name || index"
|
||||
class="border border-base-200 rounded-md p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 space-y-2">
|
||||
<!-- Definition fields -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
:value="field.type || 'text'"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleDefinitionUpdate(field, 'type', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<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 class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="!!field.required"
|
||||
@change="handleDefinitionUpdate(field, 'required', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
Obligatoire
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options for select type -->
|
||||
<textarea
|
||||
v-if="(field.type || 'text') === 'select'"
|
||||
:value="field.optionsText || (Array.isArray(field.options) ? field.options.join('\n') : '')"
|
||||
class="textarea textarea-bordered textarea-sm h-20 w-full"
|
||||
placeholder="Option 1 Option 2"
|
||||
@blur="handleOptionsUpdate(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
|
||||
<!-- Value editing -->
|
||||
<div class="pt-1 border-t border-base-200">
|
||||
<label class="label py-0">
|
||||
<span class="label-text text-xs text-base-content/60">Valeur</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="!field.type || field.type === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Valeur..."
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Valeur..."
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm w-full"
|
||||
@change="onSelectChange(field, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
v-else-if="field.type === 'boolean'"
|
||||
class="flex items-center gap-3 cursor-pointer py-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'"
|
||||
>
|
||||
{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm w-full"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-square flex-shrink-0 text-error"
|
||||
title="Supprimer ce champ"
|
||||
@click="$emit('delete-field', field.id || field.customFieldId)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-field')"
|
||||
>
|
||||
Ajouter un champ personnalisé
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
|
||||
defineProps<{
|
||||
customFields: any[]
|
||||
visibleCustomFields: any[]
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'update-custom-field': [field: any]
|
||||
'add-field': []
|
||||
'delete-field': [fieldId: string]
|
||||
'update-field-definition': [fieldId: string, data: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const handleDefinitionUpdate = (field: any, key: string, value: unknown) => {
|
||||
const fieldId = field.id || field.customFieldId
|
||||
if (!fieldId) return
|
||||
emit('update-field-definition', fieldId, { ...field, [key]: value })
|
||||
}
|
||||
|
||||
const handleOptionsUpdate = (field: any, raw: string) => {
|
||||
const fieldId = field.id || field.customFieldId
|
||||
if (!fieldId) return
|
||||
const options = raw.split('\n').map((o: string) => o.trim()).filter((o: string) => o.length > 0)
|
||||
emit('update-field-definition', fieldId, { ...field, options })
|
||||
}
|
||||
|
||||
const onSelectChange = (field: any, value: string) => {
|
||||
emit('set-custom-field-value', field, value)
|
||||
emit('update-custom-field', field)
|
||||
}
|
||||
|
||||
const onBooleanChange = (field: any, checked: boolean) => {
|
||||
emit('set-custom-field-value', field, checked ? 'true' : 'false')
|
||||
emit('update-custom-field', field)
|
||||
}
|
||||
</script>
|
||||
67
frontend/app/components/machine/MachineDetailHeader.vue
Normal file
67
frontend/app/components/machine/MachineDetailHeader.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||
<button
|
||||
@click="$emit('toggle-edit')"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
>
|
||||
<IconLucideSquarePen
|
||||
v-if="!isEditMode"
|
||||
class="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideEye
|
||||
v-else
|
||||
class="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isEditMode"
|
||||
@click="$emit('open-print')"
|
||||
type="button"
|
||||
class="btn btn-outline btn-secondary"
|
||||
>
|
||||
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
Imprimer
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour aux machines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucidePrinter from '~icons/lucide/printer'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
navigateTo('/machines')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
116
frontend/app/components/machine/MachineDocumentsCard.vue
Normal file
116
frontend/app/components/machine/MachineDocumentsCard.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm mt-6">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Documents de la machine</h2>
|
||||
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
|
||||
</div>
|
||||
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
||||
{{ files.length }} fichier{{ files.length > 1 ? 's' : '' }} sélectionné{{ files.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
:model-value="files"
|
||||
@update:model-value="$emit('update:files', $event)"
|
||||
title="Déposer des fichiers pour la machine"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="$emit('files-added', $event)"
|
||||
/>
|
||||
|
||||
<div v-if="documents.length" class="space-y-2">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||
:class="documentThumbnailClass(doc)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
|
||||
:src="doc.fileUrl || doc.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${doc.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(doc)"
|
||||
:src="documentPreviewSrc(doc)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-6 w-6"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{{ doc.name }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="$emit('download', doc)">
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="uploading"
|
||||
@click="$emit('remove', doc.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">Aucun document lié à cette machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
documentIcon,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
documents: any[]
|
||||
isEditMode: boolean
|
||||
uploading: boolean
|
||||
files: File[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:files': [files: File[]]
|
||||
'files-added': [files: File[]]
|
||||
'preview': [doc: any]
|
||||
'download': [doc: any]
|
||||
'remove': [documentId: string]
|
||||
}>()
|
||||
</script>
|
||||
230
frontend/app/components/machine/MachineInfoCard.vue
Normal file
230
frontend/app/components/machine/MachineInfoCard.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title tracking-tight">Informations de la machine</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="getMachineFieldId('name')"
|
||||
:value="machineName"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
</label>
|
||||
<select
|
||||
v-if="isEditMode"
|
||||
:value="machineSiteId"
|
||||
class="select select-bordered"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner un site</option>
|
||||
<option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:value="site.id"
|
||||
>
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineSiteName || 'Non défini' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditMode || machineReference" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="getMachineFieldId('reference')"
|
||||
:value="machineReference"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineReference }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-if="isEditMode"
|
||||
class="w-full"
|
||||
:model-value="machineConstructeurIds"
|
||||
:initial-options="machineConstructeursDisplay"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
||||
/>
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
:model-value="constructeurLinks"
|
||||
:readonly="!isEditMode"
|
||||
@update:model-value="$emit('update:constructeur-links', $event)"
|
||||
@remove="$emit('remove-constructeur-link', $event)"
|
||||
/>
|
||||
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||
<span class="text-base-content/50">Non défini</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés -->
|
||||
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
|
||||
<h4 class="font-semibold text-base-content/80 mb-3">Champs personnalisés de la machine</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
|
||||
<template v-if="isEditMode">
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="field.required"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||
>
|
||||
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="text-xs text-error">
|
||||
Type de champ non pris en charge
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode" class="mt-6 pt-4 border-t border-base-200">
|
||||
<MachineCustomFieldDefEditor
|
||||
:fields="fieldDefs.fields.value"
|
||||
:saving="fieldDefs.saving.value"
|
||||
:reorder-class="fieldDefs.reorderClass"
|
||||
:on-drag-start="fieldDefs.onDragStart"
|
||||
:on-drag-enter="fieldDefs.onDragEnter"
|
||||
:on-drop="fieldDefs.onDrop"
|
||||
:on-drag-end="fieldDefs.onDragEnd"
|
||||
@add-field="fieldDefs.addField()"
|
||||
@remove-field="fieldDefs.removeField($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
machineName: string
|
||||
machineReference: string
|
||||
machineSiteId: string
|
||||
machineSiteName: string
|
||||
sites: any[]
|
||||
machineConstructeurIds: string[]
|
||||
machineConstructeursDisplay: any[]
|
||||
hasMachineConstructeur: boolean
|
||||
constructeurLinks: ConstructeurLinkEntry[]
|
||||
visibleCustomFields: any[]
|
||||
getMachineFieldId: (fieldName: string) => string
|
||||
machineId: string
|
||||
machineCustomFieldDefs: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:machine-name': [value: string]
|
||||
'update:machine-reference': [value: string]
|
||||
'update:machine-site-id': [value: string]
|
||||
'update:constructeur-ids': [ids: unknown]
|
||||
'update:constructeur-links': [links: ConstructeurLinkEntry[]]
|
||||
'remove-constructeur-link': [constructeurId: string]
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'custom-fields-saved': []
|
||||
}>()
|
||||
|
||||
const fieldDefs = useMachineCustomFieldDefs({
|
||||
machineId: props.machineId,
|
||||
initialDefs: props.machineCustomFieldDefs,
|
||||
onSaved: () => emit('custom-fields-saved'),
|
||||
})
|
||||
|
||||
watch(() => props.machineCustomFieldDefs, (newDefs) => {
|
||||
fieldDefs.reinit(newDefs)
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
|
||||
})
|
||||
</script>
|
||||
71
frontend/app/components/machine/MachinePiecesCard.vue
Normal file
71
frontend/app/components/machine/MachinePiecesCard.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Pièces de la machine</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucune pièce associée à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="piece in pieces" :key="piece.id">
|
||||
<PieceItem
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-piece')"
|
||||
>
|
||||
Ajouter une pièce
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
defineProps<{
|
||||
pieces: any[]
|
||||
isEditMode: boolean
|
||||
collapsed: boolean
|
||||
collapseToggleToken: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-piece': [piece: any]
|
||||
'edit-piece': [piece: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
175
frontend/app/components/machine/MachineProductsCard.vue
Normal file
175
frontend/app/components/machine/MachineProductsCard.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="previewDocumentList"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Produits associés</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
Produits sélectionnés directement pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline" v-if="products.length">
|
||||
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="products.length" class="space-y-3">
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
title="Supprimer ce produit"
|
||||
@click="$emit('remove-product', (product.linkId || product.id) as string)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="product.reference" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Référence :</span>
|
||||
<span class="ml-1">{{ product.reference }}</span>
|
||||
</p>
|
||||
<p v-if="product.supplierLabel" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Fournisseurs :</span>
|
||||
<span class="ml-1">{{ product.supplierLabel }}</span>
|
||||
</p>
|
||||
<p v-if="product.priceLabel" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Prix indicatif :</span>
|
||||
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Documents liés au produit -->
|
||||
<div v-if="product.documents?.length" class="mt-2 space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/70">Documents :</p>
|
||||
<div
|
||||
v-for="doc in product.documents"
|
||||
:key="doc.id || doc.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
|
||||
>
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-4 w-4"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-base-content">{{ doc.name }}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(doc, product.documents || [])"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(doc)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucun produit n'a été associé directement à cette machine.
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-product')"
|
||||
>
|
||||
Ajouter un produit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
products: Array<{
|
||||
id?: string | null
|
||||
linkId?: string | null
|
||||
name?: string
|
||||
reference?: string | null
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}>
|
||||
}>
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewDocumentList = ref<any[]>([])
|
||||
|
||||
const openPreview = (doc: any, docs: any[]) => {
|
||||
previewDocument.value = doc
|
||||
previewDocumentList.value = docs
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user