- Add usePermissions composable (isAdmin, canEdit, canView) - Password-protected profile login with modal on profiles page - Disable all form fields for ROLE_VIEWER across edit/create pages - Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers - Add readonly prop to ModelTypeForm for category pages - Disable modal fields (sites, constructeurs) for viewers - Guard /admin routes in middleware Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
266 lines
9.0 KiB
Vue
266 lines
9.0 KiB
Vue
<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>
|
|
|
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
|
|
<TypeEditForm
|
|
:key="formKey"
|
|
v-model="draftType"
|
|
:saving="!canEdit || creating"
|
|
:resettable="false"
|
|
submit-label="Créer le type"
|
|
submit-loading-label="Création..."
|
|
@submit="handleSubmit"
|
|
/>
|
|
</div>
|
|
</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>
|
|
<span class="inline-flex items-center gap-1">
|
|
<IconLucideBox class="h-4 w-4" aria-hidden="true" />
|
|
{{ type.productRequirements?.length || 0 }} produit(s)
|
|
</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 { extractRelationId } from '~/shared/apiRelations'
|
|
import IconLucidePlus from '~icons/lucide/plus'
|
|
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
|
import IconLucideList from '~icons/lucide/list'
|
|
import IconLucideBox from '~icons/lucide/box'
|
|
|
|
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
|
const { showError } = useToast()
|
|
const { canEdit } = usePermissions()
|
|
|
|
const formKey = ref(0)
|
|
const creating = ref(false)
|
|
const initialLoading = ref(true)
|
|
|
|
const createEmptyType = () => ({
|
|
name: '',
|
|
description: '',
|
|
category: '',
|
|
maintenanceFrequency: '',
|
|
customFields: [],
|
|
componentRequirements: [],
|
|
pieceRequirements: [],
|
|
productRequirements: []
|
|
})
|
|
|
|
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 toModelTypeIri = (value) => {
|
|
if (!value) {
|
|
return undefined
|
|
}
|
|
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
|
return value
|
|
}
|
|
const relationId = extractRelationId(value)
|
|
if (relationId) {
|
|
return `/api/model_types/${relationId}`
|
|
}
|
|
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
|
}
|
|
|
|
const normalizeCustomFields = (fields = []) =>
|
|
fields
|
|
.filter(field => field?.name && field.name.trim() !== '')
|
|
.map((field, index) => ({
|
|
name: field.name,
|
|
type: field.type || '',
|
|
required: !!field.required,
|
|
options: parseOptions(field),
|
|
orderIndex: typeof field.orderIndex === 'number' ? field.orderIndex : index
|
|
}))
|
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
.map((field, index) => ({ ...field, orderIndex: index }))
|
|
|
|
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 || req?.typeComposant)
|
|
.map((req, index) => ({
|
|
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
|
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,
|
|
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
|
}))
|
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
|
|
|
const normalizePieceRequirements = (requirements = []) =>
|
|
requirements
|
|
.filter(req => req?.typePieceId || req?.typePiece)
|
|
.map((req, index) => ({
|
|
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
|
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,
|
|
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
|
}))
|
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
|
|
|
const normalizeProductRequirements = (requirements = []) =>
|
|
requirements
|
|
.filter(req => req?.typeProductId || req?.typeProduct)
|
|
.map((req, index) => ({
|
|
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
|
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,
|
|
orderIndex: typeof req.orderIndex === 'number' ? req.orderIndex : index
|
|
}))
|
|
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
|
.map((req, index) => ({ ...req, orderIndex: index }))
|
|
|
|
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),
|
|
productRequirements: normalizeProductRequirements(typeData.productRequirements)
|
|
})
|
|
|
|
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>
|