feat: reorganize machine skeleton pages
This commit is contained in:
143
app/pages/machine-skeleton/index.vue
Normal file
143
app/pages/machine-skeleton/index.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8">
|
||||
<!-- Machine Types List -->
|
||||
<div class="my-8">
|
||||
<!-- Header with Add Button -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">
|
||||
Squelettes de machine
|
||||
</h2>
|
||||
<NuxtLink to="/machine-skeleton/new" class="btn btn-primary">
|
||||
<IconLucidePlus
|
||||
class="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Créer un type
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Categories Tabs -->
|
||||
<div class="tabs tabs-boxed mb-6">
|
||||
<a
|
||||
v-for="category in categories"
|
||||
:key="category"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': selectedCategory === category }"
|
||||
@click="selectedCategory = category"
|
||||
>
|
||||
{{ category }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Machine Types Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="type in filteredTypes"
|
||||
:key="type.id"
|
||||
class="card bg-base-100 shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="card-title text-lg">
|
||||
{{ type.name }}
|
||||
</h3>
|
||||
<div class="badge badge-primary">
|
||||
{{ type.category }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">
|
||||
{{ type.description }}
|
||||
</p>
|
||||
<div class="space-y-2 text-sm text-gray-500">
|
||||
<div class="flex items-center gap-2">
|
||||
<IconLucidePackage class="w-4 h-4" aria-hidden="true" />
|
||||
<span>{{ type.componentRequirements?.length || 0 }} famille(s) de composants</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconLucideLayoutGrid class="w-4 h-4" aria-hidden="true" />
|
||||
<span>{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteType(type)">
|
||||
Supprimer
|
||||
</button>
|
||||
<NuxtLink :to="`/type/${type.id}`" class="btn btn-sm btn-outline">
|
||||
Voir détails
|
||||
</NuxtLink>
|
||||
<button class="btn btn-sm btn-primary">
|
||||
Utiliser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="filteredTypes.length === 0" class="text-center py-12">
|
||||
<div class="avatar placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-16">
|
||||
<IconLucideLayoutGrid class="w-8 h-8" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-600 mt-4">
|
||||
Aucun type trouvé
|
||||
</h3>
|
||||
<p class="text-gray-500">
|
||||
Aucun type de machine ne correspond à cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import IconLucideLayoutGrid from '~icons/lucide/layout-grid'
|
||||
|
||||
const { machineTypes, loading, loadMachineTypes, deleteMachineType } = useMachineTypesApi()
|
||||
|
||||
const categories = ref([
|
||||
'Toutes',
|
||||
'Production',
|
||||
'Transformation',
|
||||
'Manutention',
|
||||
'Traitement',
|
||||
'Contrôle'
|
||||
])
|
||||
|
||||
const selectedCategory = ref('Toutes')
|
||||
|
||||
const filteredTypes = computed(() => {
|
||||
if (selectedCategory.value === 'Toutes') {
|
||||
return machineTypes.value
|
||||
}
|
||||
return machineTypes.value.filter(type => type.category === selectedCategory.value)
|
||||
})
|
||||
|
||||
const confirmDeleteType = async (type) => {
|
||||
const { showError, showSuccess } = useToast()
|
||||
|
||||
if (confirm(`Êtes-vous sûr de vouloir supprimer le type "${type.name}" ? Cette action est irréversible.`)) {
|
||||
try {
|
||||
const result = await deleteMachineType(type.id)
|
||||
if (result.success) {
|
||||
showSuccess(`Type "${type.name}" supprimé avec succès`)
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
showError(`Erreur lors de la suppression: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load machine types on mount
|
||||
onMounted(async () => {
|
||||
await loadMachineTypes()
|
||||
})
|
||||
</script>
|
||||
216
app/pages/machine-skeleton/new.vue
Normal file
216
app/pages/machine-skeleton/new.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<main class="container mx-auto px-6 py-8 space-y-8">
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body space-y-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<IconLucidePlus
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="card-title text-2xl">
|
||||
Nouveau type de machine
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500">
|
||||
Complétez les informations puis enregistrez pour générer le nouveau type.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypeEditForm
|
||||
:key="formKey"
|
||||
v-model="draftType"
|
||||
:saving="creating"
|
||||
:resettable="false"
|
||||
submit-label="Créer le type"
|
||||
submit-loading-label="Création..."
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div v-if="initialLoading" class="text-center py-12 text-sm text-gray-500">
|
||||
Chargement des types existants...
|
||||
</div>
|
||||
<template v-else>
|
||||
<div v-if="recentTypes.length" class="space-y-4">
|
||||
<h3 class="text-xl font-semibold">
|
||||
Types générés récemment
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<article
|
||||
v-for="type in recentTypes"
|
||||
:key="type.id"
|
||||
class="card bg-base-100 shadow-md border border-base-200"
|
||||
>
|
||||
<div class="card-body space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="card-title text-base">
|
||||
{{ type.name }}
|
||||
</h4>
|
||||
<span v-if="type.category" class="badge badge-outline badge-sm">{{ type.category }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 line-clamp-3">
|
||||
{{ type.description || 'Aucune description' }}
|
||||
</p>
|
||||
<div class="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IconLucideClipboardList class="h-4 w-4" aria-hidden="true" />
|
||||
{{ type.componentRequirements?.length || 0 }} famille(s)
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<IconLucideList class="h-4 w-4" aria-hidden="true" />
|
||||
{{ type.pieceRequirements?.length || 0 }} groupe(s) de pièces
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-12 text-sm text-gray-500">
|
||||
Aucun type généré récemment.
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||
import IconLucideList from '~icons/lucide/list'
|
||||
|
||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||
const { showError } = useToast()
|
||||
|
||||
const formKey = ref(0)
|
||||
const creating = ref(false)
|
||||
const initialLoading = ref(true)
|
||||
|
||||
const createEmptyType = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
maintenanceFrequency: '',
|
||||
customFields: [],
|
||||
componentRequirements: [],
|
||||
pieceRequirements: []
|
||||
})
|
||||
|
||||
const draftType = ref(createEmptyType())
|
||||
|
||||
const recentTypes = computed(() => machineTypes.value.slice(-3).reverse())
|
||||
|
||||
onMounted(async () => {
|
||||
if (!machineTypes.value.length) {
|
||||
try {
|
||||
initialLoading.value = true
|
||||
await loadMachineTypes()
|
||||
} finally {
|
||||
initialLoading.value = false
|
||||
}
|
||||
} else {
|
||||
initialLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const parseOptions = (field = {}) => {
|
||||
if (field.type !== 'select') { return [] }
|
||||
if (field.optionsText && typeof field.optionsText === 'string') {
|
||||
return field.optionsText
|
||||
.split('\n')
|
||||
.map(option => option.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
if (Array.isArray(field.options)) {
|
||||
return field.options
|
||||
.map(option => String(option).trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const normalizeCustomFields = (fields = []) =>
|
||||
fields
|
||||
.filter(field => field?.name && field.name.trim() !== '')
|
||||
.map(field => ({
|
||||
name: field.name,
|
||||
type: field.type || '',
|
||||
required: !!field.required,
|
||||
options: parseOptions(field)
|
||||
}))
|
||||
|
||||
const toIntegerOrNull = (value, fallback = null) => {
|
||||
if (value === '' || value === undefined || value === null) {
|
||||
return fallback
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : fallback
|
||||
}
|
||||
|
||||
const normalizeComponentRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeComposantId)
|
||||
.map(req => ({
|
||||
typeComposantId: req.typeComposantId,
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 1),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
required: req.required ?? true,
|
||||
allowNewModels: req.allowNewModels ?? true
|
||||
}))
|
||||
|
||||
const normalizePieceRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typePieceId)
|
||||
.map(req => ({
|
||||
typePieceId: req.typePieceId,
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
required: req.required ?? false,
|
||||
allowNewModels: req.allowNewModels ?? true
|
||||
}))
|
||||
|
||||
const buildPayload = typeData => ({
|
||||
name: typeData.name,
|
||||
description: typeData.description,
|
||||
category: typeData.category,
|
||||
maintenanceFrequency: typeData.maintenanceFrequency,
|
||||
customFields: normalizeCustomFields(typeData.customFields),
|
||||
componentRequirements: normalizeComponentRequirements(typeData.componentRequirements),
|
||||
pieceRequirements: normalizePieceRequirements(typeData.pieceRequirements)
|
||||
})
|
||||
|
||||
const resetForm = () => {
|
||||
draftType.value = createEmptyType()
|
||||
formKey.value += 1
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!draftType.value.name?.trim()) {
|
||||
showError('Le nom du type est requis.')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = buildPayload(draftType.value)
|
||||
|
||||
creating.value = true
|
||||
const result = await createMachineType(payload)
|
||||
creating.value = false
|
||||
|
||||
if (result?.success) {
|
||||
resetForm()
|
||||
} else if (result?.error) {
|
||||
showError(result.error)
|
||||
} else {
|
||||
showError('Impossible de créer le type.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user