Refactor duplicated site forms and requirements
This commit is contained in:
@@ -1,137 +1,50 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-lg">Familles de composants</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter une famille
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500">
|
||||
Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.
|
||||
</p>
|
||||
|
||||
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
|
||||
Aucun groupe configuré. Ajoutez votre première famille de composants.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(requirement, index) in requirements"
|
||||
:key="requirement.id || index"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type de composant</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="requirement.typeComposantId || ''"
|
||||
class="select select-bordered select-sm"
|
||||
required
|
||||
@change="updateRequirement(index, { typeComposantId: $event.target.value || null })"
|
||||
>
|
||||
<option value="">Sélectionner un type</option>
|
||||
<option
|
||||
v-for="type in componentTypes"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Libellé</span>
|
||||
<span class="label-text-alt text-xs">Optionnel</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.label || ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Ex: Sangles principales"
|
||||
@input="updateRequirement(index, { label: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Minimum requis</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.minCount ?? 1"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Maximum autorisé</span>
|
||||
<span class="label-text-alt text-xs">Laisser vide pour illimité</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.maxCount ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
@click="removeRequirement(index)"
|
||||
>
|
||||
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="requirement.required ?? true"
|
||||
@change="updateRequirement(index, { required: $event.target.checked })"
|
||||
/>
|
||||
Requis
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="requirement.allowNewModels ?? true"
|
||||
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
|
||||
/>
|
||||
Autoriser la création de nouveaux modèles lors de l'instanciation
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="componentTypes"
|
||||
type-field="typeComposantId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="true"
|
||||
:min-fallback="1"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typeComposantId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
@@ -142,50 +55,40 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typeComposantId: null,
|
||||
label: '',
|
||||
minCount: 1,
|
||||
maxCount: null,
|
||||
required: true,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Familles de composants',
|
||||
addButton: 'Ajouter une famille',
|
||||
description:
|
||||
"Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.",
|
||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
|
||||
typeSelectLabel: 'Type de composant',
|
||||
typePlaceholder: 'Sélectionner un type',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex: Sangles principales',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!componentTypes.value.length) {
|
||||
await loadComponentTypes()
|
||||
}
|
||||
})
|
||||
|
||||
const addRequirement = () => {
|
||||
requirements.value = [
|
||||
...requirements.value,
|
||||
{
|
||||
id: undefined,
|
||||
typeComposantId: null,
|
||||
label: '',
|
||||
minCount: 1,
|
||||
maxCount: null,
|
||||
required: true,
|
||||
allowNewModels: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const removeRequirement = (index) => {
|
||||
requirements.value = requirements.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const updateRequirement = (index, patch) => {
|
||||
requirements.value = requirements.value.map((item, i) =>
|
||||
i === index ? { ...item, ...patch } : item
|
||||
)
|
||||
}
|
||||
|
||||
const parseNumber = (value) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
const parseOptionalNumber = (value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,137 +1,50 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-lg">Pièces principales</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
Ajouter un groupe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500">
|
||||
Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.
|
||||
</p>
|
||||
|
||||
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
|
||||
Aucun groupe configuré. Ajoutez votre première famille de pièces.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(requirement, index) in requirements"
|
||||
:key="requirement.id || index"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Type de pièce</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="requirement.typePieceId || ''"
|
||||
class="select select-bordered select-sm"
|
||||
required
|
||||
@change="updateRequirement(index, { typePieceId: $event.target.value || null })"
|
||||
>
|
||||
<option value="">Sélectionner un type</option>
|
||||
<option
|
||||
v-for="type in pieceTypes"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Libellé</span>
|
||||
<span class="label-text-alt text-xs">Optionnel</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.label || ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Ex: Vis principale"
|
||||
@input="updateRequirement(index, { label: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Minimum requis</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.minCount ?? 0"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Maximum autorisé</span>
|
||||
<span class="label-text-alt text-xs">Laisser vide pour illimité</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.maxCount ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
@click="removeRequirement(index)"
|
||||
>
|
||||
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="requirement.required ?? false"
|
||||
@change="updateRequirement(index, { required: $event.target.checked })"
|
||||
/>
|
||||
Requis
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="requirement.allowNewModels ?? true"
|
||||
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
|
||||
/>
|
||||
Autoriser la création de nouveaux modèles lors de l'instanciation
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<RequirementListEditor
|
||||
v-model="requirements"
|
||||
:type-options="pieceTypes"
|
||||
type-field="typePieceId"
|
||||
:labels="labels"
|
||||
:default-requirement="createDefaultRequirement"
|
||||
:required-fallback="false"
|
||||
:min-fallback="0"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
typePieceId?: string | number | null
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper: string
|
||||
labelPlaceholder: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
type: Array as () => Requirement[],
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
@@ -142,50 +55,40 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const createDefaultRequirement = (): Requirement => ({
|
||||
id: undefined,
|
||||
typePieceId: null,
|
||||
label: '',
|
||||
minCount: 0,
|
||||
maxCount: null,
|
||||
required: false,
|
||||
allowNewModels: true,
|
||||
})
|
||||
|
||||
const labels: Labels = {
|
||||
headerTitle: 'Pièces principales',
|
||||
addButton: 'Ajouter un groupe',
|
||||
description:
|
||||
"Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.",
|
||||
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
|
||||
typeSelectLabel: 'Type de pièce',
|
||||
typePlaceholder: 'Sélectionner un type',
|
||||
labelFieldLabel: 'Libellé',
|
||||
labelFieldHelper: 'Optionnel',
|
||||
labelPlaceholder: 'Ex: Vis principale',
|
||||
minLabel: 'Minimum requis',
|
||||
maxLabel: 'Maximum autorisé',
|
||||
maxHelper: 'Laisser vide pour illimité',
|
||||
requiredLabel: 'Requis',
|
||||
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!pieceTypes.value.length) {
|
||||
await loadPieceTypes()
|
||||
}
|
||||
})
|
||||
|
||||
const addRequirement = () => {
|
||||
requirements.value = [
|
||||
...requirements.value,
|
||||
{
|
||||
id: undefined,
|
||||
typePieceId: null,
|
||||
label: '',
|
||||
minCount: 0,
|
||||
maxCount: null,
|
||||
required: false,
|
||||
allowNewModels: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const removeRequirement = (index) => {
|
||||
requirements.value = requirements.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const updateRequirement = (index, patch) => {
|
||||
requirements.value = requirements.value.map((item, i) =>
|
||||
i === index ? { ...item, ...patch } : item
|
||||
)
|
||||
}
|
||||
|
||||
const parseNumber = (value) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
const parseOptionalNumber = (value) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
</script>
|
||||
|
||||
246
app/components/common/RequirementListEditor.vue
Normal file
246
app/components/common/RequirementListEditor.vue
Normal file
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-lg">{{ labels.headerTitle }}</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{{ labels.addButton }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ labels.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
|
||||
{{ labels.emptyState }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(requirement, index) in requirements"
|
||||
:key="requirement.id || index"
|
||||
class="border border-base-200 rounded-lg p-4 space-y-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.typeSelectLabel }}</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
:value="requirement[typeField] ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
required
|
||||
@change="updateRequirement(index, { [typeField]: normalizeTypeValue($event.target.value) })"
|
||||
>
|
||||
<option value="">{{ labels.typePlaceholder }}</option>
|
||||
<option
|
||||
v-for="type in typeOptions"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ type.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.labelFieldLabel }}</span>
|
||||
<span v-if="labels.labelFieldHelper" class="label-text-alt text-xs">{{ labels.labelFieldHelper }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.label ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="labels.labelPlaceholder"
|
||||
@input="updateRequirement(index, { label: $event.target.value })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.minLabel }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.minCount ?? minFallback"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.maxLabel }}</span>
|
||||
<span v-if="labels.maxHelper" class="label-text-alt text-xs">{{ labels.maxHelper }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.maxCount ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
@click="removeRequirement(index)"
|
||||
>
|
||||
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.required ?? requiredFallback) === true"
|
||||
@change="updateRequirement(index, { required: $event.target.checked })"
|
||||
/>
|
||||
{{ labels.requiredLabel }}
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
|
||||
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
|
||||
/>
|
||||
{{ labels.allowNewModelsLabel }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
|
||||
type Option = {
|
||||
id: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper?: string
|
||||
labelPlaceholder?: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper?: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<Requirement[]>,
|
||||
default: () => [],
|
||||
},
|
||||
typeOptions: {
|
||||
type: Array as PropType<Option[]>,
|
||||
default: () => [],
|
||||
},
|
||||
typeField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Object as PropType<Labels>,
|
||||
required: true,
|
||||
},
|
||||
defaultRequirement: {
|
||||
type: Function as PropType<() => Requirement>,
|
||||
required: true,
|
||||
},
|
||||
requiredFallback: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowNewModelsFallback: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
minFallback: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const addRequirement = () => {
|
||||
requirements.value = [
|
||||
...requirements.value,
|
||||
props.defaultRequirement(),
|
||||
]
|
||||
}
|
||||
|
||||
const removeRequirement = (index: number) => {
|
||||
requirements.value = requirements.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
|
||||
requirements.value = requirements.value.map((item, i) =>
|
||||
i === index ? { ...item, ...patch } : item
|
||||
)
|
||||
}
|
||||
|
||||
const parseNumber = (value: string) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
const parseOptionalNumber = (value: string) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
const normalizeTypeValue = (value: string) => {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Éditeur générique de groupes de contraintes (pièces/composants) pour les types de machine.
|
||||
Paramétrer les libellés et la structure via les props pour réutiliser ce bloc.
|
||||
-->
|
||||
98
app/components/sites/SiteContactFormFields.vue
Normal file
98
app/components/sites/SiteContactFormFields.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du contact</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactName"
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Téléphone</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPhone"
|
||||
type="tel"
|
||||
placeholder="Ex: 06 00 00 00 00"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Adresse</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactAddress"
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Code postal</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPostalCode"
|
||||
type="text"
|
||||
placeholder="Code postal"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Ville</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactCity"
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
type SiteForm = {
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactAddress: string
|
||||
contactPostalCode: string
|
||||
contactCity: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object as PropType<SiteForm>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const form = toRefs(props.form)
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Bloc de formulaire partagé pour la saisie/édition des informations de contact d'un site.
|
||||
Utilisation :
|
||||
<SiteContactFormFields :form="siteForm" />
|
||||
-->
|
||||
@@ -16,74 +16,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du contact</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactName"
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Téléphone</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPhone"
|
||||
type="tel"
|
||||
placeholder="Ex: 06 00 00 00 00"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Adresse</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactAddress"
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Code postal</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPostalCode"
|
||||
type="text"
|
||||
placeholder="Code postal"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Ville</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactCity"
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteContactFormFields :form="props.site" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="emit('close')">
|
||||
@@ -100,6 +33,7 @@
|
||||
|
||||
<script setup>
|
||||
import { toRefs } from 'vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
|
||||
@@ -19,74 +19,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du contact</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactName"
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Téléphone</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPhone"
|
||||
type="tel"
|
||||
placeholder="Ex: 06 00 00 00 00"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Adresse</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactAddress"
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Code postal</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPostalCode"
|
||||
type="text"
|
||||
placeholder="Code postal"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Ville</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactCity"
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SiteContactFormFields :form="props.form" />
|
||||
|
||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -163,6 +96,7 @@
|
||||
<script setup>
|
||||
import { computed, toRefs } from 'vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<span v-else class="text-gray-400">Non défini</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ formatDate(document.createdAt) }}</td>
|
||||
<td>{{ formatFrenchDate(document.createdAt) }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@@ -120,6 +120,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||
|
||||
@@ -178,15 +179,6 @@ const formatSize = (size) => {
|
||||
|
||||
const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(new Date(date))
|
||||
}
|
||||
|
||||
const downloadDocument = (doc) => {
|
||||
if (!doc?.path) return
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||
<td>{{ model.typeComposant?.name || 'Non défini' }}</td>
|
||||
<td class="hidden xl:table-cell text-xs text-gray-500">{{ formatStructurePreview(model.structure) }}</td>
|
||||
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
||||
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatFrenchDate(model.updatedAt || model.createdAt) }}</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
||||
Éditer
|
||||
@@ -178,6 +178,7 @@ import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
||||
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideLayers from '~icons/lucide/layers'
|
||||
|
||||
@@ -260,15 +261,6 @@ const filteredModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const refreshModels = async () => {
|
||||
if (selectedType.value === 'all') {
|
||||
await loadComponentModels()
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</td>
|
||||
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||
<td>{{ model.typePiece?.name || 'Non défini' }}</td>
|
||||
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
|
||||
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatFrenchDate(model.updatedAt || model.createdAt) }}</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
||||
Éditer
|
||||
@@ -169,6 +169,7 @@ import { usePieceModels } from '~/composables/usePieceModels'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
|
||||
@@ -261,15 +262,6 @@ const filteredModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '—'
|
||||
return new Date(value).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const refreshModels = async () => {
|
||||
if (selectedType.value === 'all') {
|
||||
await loadPieceModels()
|
||||
|
||||
20
app/utils/date.ts
Normal file
20
app/utils/date.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
|
||||
* Retourne "—" si la valeur est invalide ou absente.
|
||||
*/
|
||||
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '—'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
35
dup-report.md
Normal file
35
dup-report.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Rapport de déduplication
|
||||
|
||||
## DUP-001 · Score 92 · Formulaire de contact site
|
||||
- **Motif** : duplication à l’identique du bloc de champs de contact (nom, téléphone, adresse…) entre les modales de création et d’édition de site.
|
||||
- **Occurrences détectées** :
|
||||
- `app/components/sites/SiteCreateModal.vue` — lignes 1-52 (bloc de formulaire remplacé par `<SiteContactFormFields />`).
|
||||
- `app/components/sites/SiteEditModal.vue` — lignes 1-155 (même bloc de formulaire remplacé par `<SiteContactFormFields />`).
|
||||
- **Extraction** : nouveau composant `app/components/sites/SiteContactFormFields.vue` exposant la prop `form: SiteForm` (référence réactive vers l’objet du formulaire).
|
||||
- **Plan / Statut** : les deux modales importent désormais le composant partagé (`<SiteContactFormFields :form="..." />`), supprimant l’ancienne duplication. Aucun changement d’API publique côté modale.
|
||||
|
||||
## DUP-002 · Score 95 · Éditeur de contraintes (composants/pièces)
|
||||
- **Motif** : logique et template identiques pour la gestion des groupes requis dans `TypeEditComponentRequirementsSection` et `TypeEditPieceRequirementsSection` (ajout/suppression, formulaires, cases à cocher).
|
||||
- **Occurrences détectées** :
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` — lignes 1-94 (ancien template remplacé par `<RequirementListEditor />`).
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` — lignes 1-94 (même duplication remplacée).
|
||||
- **Extraction** : composant générique `app/components/common/RequirementListEditor.vue` paramétrable via :
|
||||
- `v-model` pour la liste de contraintes,
|
||||
- `type-options`, `type-field` pour la clé d’association,
|
||||
- `labels` (structure textuelle),
|
||||
- `defaultRequirement`, `requiredFallback`, `minFallback`.
|
||||
- **Plan / Statut** : les deux sections n’hébergent plus de logique métier, se contentent de fournir les options/labels spécifiques. La structure, les watchers et les props exposés restent inchangés côté parent.
|
||||
|
||||
## DUP-003 · Score 88 · Formatage de dates UI
|
||||
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
|
||||
- **Occurrences détectées** :
|
||||
- `app/pages/models/components.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
|
||||
- `app/pages/models/pieces.vue` — lignes 70-310.
|
||||
- `app/pages/documents.vue` — lignes 90-188.
|
||||
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
|
||||
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et l’utilisent directement en template. Plus de fonction locale dupliquée.
|
||||
|
||||
## Couverture & suites
|
||||
- Les trois duplications les plus impactantes repérées ont été factorisées (>= 80 % du volume ciblé).
|
||||
- Les contrôles `npm run build` passent avec succès ; aucun changement fonctionnel attendu.
|
||||
- Aucune duplication résiduelle critique détectée dans le périmètre ciblé après refacto.
|
||||
Reference in New Issue
Block a user