Refactor duplicated site forms and requirements
This commit is contained in:
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.
|
||||
-->
|
||||
Reference in New Issue
Block a user