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:
41
frontend/app/components/common/ConfirmModal.vue
Normal file
41
frontend/app/components/common/ConfirmModal.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="confirmState.open"
|
||||
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-md mx-4 p-6 space-y-4">
|
||||
<h3 class="font-bold text-lg">
|
||||
{{ confirmState.title }}
|
||||
</h3>
|
||||
|
||||
<p class="whitespace-pre-line text-base-content/80">
|
||||
{{ confirmState.message }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ confirmState.cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="confirmState.dangerous ? 'btn-error' : 'btn-primary'"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmState.confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
const { confirmState, handleConfirm, handleCancel } = useConfirm()
|
||||
</script>
|
||||
173
frontend/app/components/common/CustomFieldDisplay.vue
Normal file
173
frontend/app/components/common/CustomFieldDisplay.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fields.length"
|
||||
class="mt-4 pt-4 border-t border-base-200"
|
||||
>
|
||||
<h5 class="text-sm font-medium text-base-content/80 mb-3">
|
||||
Champs personnalisés
|
||||
</h5>
|
||||
<div :class="layoutClass">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{
|
||||
resolveFieldName(field)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="resolveFieldRequired(field)"
|
||||
class="label-text-alt text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
|
||||
<!-- Mode édition -->
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option
|
||||
v-for="option in resolveFieldOptions(field)"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div
|
||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="text-sm">{{
|
||||
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type TEXTAREA -->
|
||||
<textarea
|
||||
v-else-if="resolveFieldType(field) === 'textarea'"
|
||||
:value="field.value ?? ''"
|
||||
class="textarea textarea-bordered textarea-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
/>
|
||||
|
||||
<!-- Fallback: input text -->
|
||||
<input
|
||||
v-else
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Mode lecture seule -->
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: any[]
|
||||
isEditMode: boolean
|
||||
columns?: 1 | 2
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'field-input': [field: any, value: string]
|
||||
'field-blur': [field: any]
|
||||
}>()
|
||||
|
||||
const layoutClass = computed(() =>
|
||||
props.columns === 2
|
||||
? 'grid grid-cols-1 md:grid-cols-2 gap-4'
|
||||
: 'space-y-3',
|
||||
)
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
}
|
||||
|
||||
function onBooleanChange(field: any, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false'
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
emit('field-blur', field)
|
||||
}
|
||||
|
||||
function onBlur(field: any) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
</script>
|
||||
83
frontend/app/components/common/CustomFieldInputGrid.vue
Normal file
83
frontend/app/components/common/CustomFieldInputGrid.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<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
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
defineProps<{
|
||||
fields: CustomFieldInput[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
308
frontend/app/components/common/DataTable.vue
Normal file
308
frontend/app/components/common/DataTable.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar + counter row -->
|
||||
<div
|
||||
v-if="$slots.toolbar || showCounter || showPerPage"
|
||||
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="dt-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="dt-per-page"
|
||||
:value="pagination.perPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
|
||||
{{ pagination.pageItems }} / {{ pagination.totalItems }}
|
||||
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
|
||||
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
|
||||
<slot name="loading">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
|
||||
<template v-else-if="isEmpty && !hasFilterableColumns">
|
||||
<slot name="empty">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- No results without filterable columns -->
|
||||
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
|
||||
<slot name="no-results">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ noResultsMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto overflow-y-clip relative rounded-lg border border-base-300/40">
|
||||
<!-- Loading overlay (keeps table & filter inputs visible) -->
|
||||
<div
|
||||
v-if="loading && hasFilterableColumns"
|
||||
class="absolute inset-0 bg-base-100/60 backdrop-blur-[1px] z-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<table :class="['table table-sm md:table-md', tableClass]">
|
||||
<thead>
|
||||
<!-- Header labels + sort -->
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.width,
|
||||
col.class,
|
||||
col.headerClass,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`header-${col.key}`" :column="col">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1',
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-base-content' : '',
|
||||
]"
|
||||
@click="col.sortable && handleHeaderSort(col)"
|
||||
>
|
||||
{{ col.label }}
|
||||
<template v-if="col.sortable">
|
||||
<IconLucideChevronUp
|
||||
v-if="isSortedAsc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié croissant"
|
||||
/>
|
||||
<IconLucideChevronDown
|
||||
v-else-if="isSortedDesc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié décroissant"
|
||||
/>
|
||||
<IconLucideChevronsUpDown
|
||||
v-else
|
||||
class="h-3.5 w-3.5 opacity-30"
|
||||
aria-label="Triable"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
<th v-if="expandable" class="w-12" />
|
||||
</tr>
|
||||
<!-- Filter inputs row -->
|
||||
<tr v-if="hasFilterableColumns">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="`filter-${col.key}`"
|
||||
class="p-1"
|
||||
:class="{ 'hidden sm:table-cell': col.hiddenMobile }"
|
||||
>
|
||||
<input
|
||||
v-if="col.filterable"
|
||||
type="text"
|
||||
class="input input-bordered input-xs w-full"
|
||||
:placeholder="col.filterPlaceholder || 'Filtrer…'"
|
||||
:value="columnFilters[col.key] ?? ''"
|
||||
@input="handleFilterInput(col.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="expandable" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- No results message (inside table to keep headers visible) -->
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="expandable ? columns.length + 1 : columns.length" class="text-center py-8">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{{ isEmpty ? emptyMessage : noResultsMessage }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="(row, idx) in rows" :key="getRowKey(row)">
|
||||
<tr>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.class,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${col.key}`" :row="row" :column="col" :index="idx">
|
||||
{{ row[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="expandable" class="text-center">
|
||||
<button
|
||||
v-if="!canExpand || canExpand(row)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="emit('toggle-expand', getRowKey(row))"
|
||||
>
|
||||
{{ isExpanded(row) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded row -->
|
||||
<tr v-if="expandable && isExpanded(row)">
|
||||
<td :colspan="columns.length + 1" class="bg-base-200/30 p-4 border-t border-base-200/80">
|
||||
<slot name="row-expanded" :row="row" :index="idx" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination && pagination.totalPages > 1"
|
||||
:current-page="pagination.currentPage"
|
||||
:total-pages="pagination.totalPages"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataTableColumn, DataTableSort, DataTablePagination, DataTableColumnFilters } from '~/shared/types/dataTable'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideChevronUp from '~icons/lucide/chevron-up'
|
||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: DataTableColumn[]
|
||||
rows: any[]
|
||||
rowKey?: string
|
||||
loading?: boolean
|
||||
sort?: DataTableSort | null
|
||||
pagination?: DataTablePagination | null
|
||||
columnFilters?: DataTableColumnFilters
|
||||
emptyMessage?: string
|
||||
noResultsMessage?: string
|
||||
expandable?: boolean
|
||||
expandedKeys?: Set<string>
|
||||
canExpand?: (row: any) => boolean
|
||||
tableClass?: string
|
||||
showCounter?: boolean
|
||||
showPerPage?: boolean
|
||||
}>(), {
|
||||
rowKey: 'id',
|
||||
loading: false,
|
||||
sort: null,
|
||||
pagination: null,
|
||||
columnFilters: () => ({}),
|
||||
emptyMessage: 'Aucune donnée disponible.',
|
||||
noResultsMessage: 'Aucun résultat ne correspond à vos critères.',
|
||||
expandable: false,
|
||||
expandedKeys: () => new Set<string>(),
|
||||
canExpand: undefined,
|
||||
tableClass: '',
|
||||
showCounter: true,
|
||||
showPerPage: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sort', sort: DataTableSort): void
|
||||
(e: 'update:currentPage', page: number): void
|
||||
(e: 'update:perPage', perPage: number): void
|
||||
(e: 'update:columnFilters', filters: DataTableColumnFilters): void
|
||||
(e: 'toggle-expand', key: string): void
|
||||
}>()
|
||||
|
||||
const hasFilterableColumns = computed(() =>
|
||||
props.columns.some(col => col.filterable),
|
||||
)
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (props.pagination) {
|
||||
return props.pagination.totalItems === 0
|
||||
}
|
||||
return props.rows.length === 0
|
||||
})
|
||||
|
||||
const getRowKey = (row: any): string => {
|
||||
return String(row[props.rowKey] ?? '')
|
||||
}
|
||||
|
||||
const isExpanded = (row: any): boolean => {
|
||||
return props.expandedKeys?.has(getRowKey(row)) ?? false
|
||||
}
|
||||
|
||||
const sortKeyForColumn = (col: DataTableColumn): string => {
|
||||
return col.sortKey ?? col.key
|
||||
}
|
||||
|
||||
const isSortedAsc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'asc'
|
||||
}
|
||||
|
||||
const isSortedDesc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'desc'
|
||||
}
|
||||
|
||||
const handleHeaderSort = (col: DataTableColumn) => {
|
||||
const key = sortKeyForColumn(col)
|
||||
const currentDirection = props.sort?.field === key ? props.sort.direction : null
|
||||
|
||||
emit('sort', {
|
||||
field: key,
|
||||
direction: currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
})
|
||||
}
|
||||
|
||||
let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleFilterInput = (key: string, value: string) => {
|
||||
if (filterDebounceTimer) clearTimeout(filterDebounceTimer)
|
||||
filterDebounceTimer = setTimeout(() => {
|
||||
const updated = { ...props.columnFilters, [key]: value }
|
||||
// Remove empty filter keys
|
||||
for (const k of Object.keys(updated)) {
|
||||
if (!updated[k]) delete updated[k]
|
||||
}
|
||||
emit('update:columnFilters', updated)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const alignClass = (col: DataTableColumn): string => {
|
||||
if (col.align === 'center') return 'text-center'
|
||||
if (col.align === 'right') return 'text-right'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
118
frontend/app/components/common/DocumentListInline.vue
Normal file
118
frontend/app/components/common/DocumentListInline.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.id || document.path || document.name"
|
||||
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(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-6 w-6"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{{ document.name }}
|
||||
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Modifier"
|
||||
@click="$emit('edit', document)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="$emit('preview', document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(document)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="deleteDisabled"
|
||||
@click="$emit('delete', document.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-base-content/70">
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
documentIcon,
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
import type { Document } from '~/composables/useDocuments'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
canDelete?: boolean
|
||||
canEdit?: boolean
|
||||
deleteDisabled?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
canDelete: false,
|
||||
canEdit: false,
|
||||
deleteDisabled: false,
|
||||
emptyText: 'Aucun document.',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'preview', document: Document): void
|
||||
(e: 'delete', documentId: string): void
|
||||
(e: 'edit', document: Document): void
|
||||
}>()
|
||||
</script>
|
||||
97
frontend/app/components/common/EntityHistorySection.vue
Normal file
97
frontend/app/components/common/EntityHistorySection.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="entries.length" class="badge badge-outline">
|
||||
{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l'historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="diffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in diffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
type HistoryDiffEntry,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
action: string
|
||||
createdAt: string
|
||||
actor?: { label?: string } | null
|
||||
diff?: Record<string, { from?: unknown; to?: unknown }> | null
|
||||
snapshot?: { name?: string } | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entries: HistoryEntry[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
fieldLabels: Record<string, string>
|
||||
}>()
|
||||
|
||||
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
|
||||
historyDiffEntries(entry, props.fieldLabels)
|
||||
</script>
|
||||
170
frontend/app/components/common/EntityVersionList.vue
Normal file
170
frontend/app/components/common/EntityVersionList.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Versions</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Historique des versions avec possibilite de restauration.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="versions.length" class="badge badge-outline">
|
||||
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des versions...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
|
||||
Aucune version enregistree.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in versions"
|
||||
:key="entry.version"
|
||||
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
|
||||
<span
|
||||
v-if="entry.version === currentVersion"
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
actuelle
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.action === 'restore'"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
restauration
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||
<span>{{ actionLabel(entry.action) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(entry.createdAt) }}</span>
|
||||
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
|
||||
</div>
|
||||
<div v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(change, field) in entry.diff"
|
||||
:key="field"
|
||||
class="badge badge-ghost badge-xs"
|
||||
>
|
||||
{{ formatDiffEntry(String(field), change) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canRestore && entry.version !== currentVersion"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="restoring"
|
||||
@click="handleRestore(entry.version)"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<VersionRestoreModal
|
||||
:visible="modalVisible"
|
||||
:preview="previewData"
|
||||
:restoring="restoring"
|
||||
:field-labels="fieldLabels"
|
||||
:entity-type="entityType"
|
||||
@close="modalVisible = false"
|
||||
@confirm="confirmRestore"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, toRef } from 'vue'
|
||||
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
|
||||
import VersionRestoreModal from './VersionRestoreModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
entityId: string
|
||||
fieldLabels: Record<string, string>
|
||||
/** Increment this value to force a refresh of the versions list */
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
restored: []
|
||||
}>()
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const canRestore = computed(() => canEdit.value)
|
||||
|
||||
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
|
||||
entityType: props.entityType,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
|
||||
const currentVersion = computed(() => {
|
||||
if (versions.value.length === 0) return null
|
||||
return versions.value[0]?.version ?? null
|
||||
})
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const previewData = ref<RestorePreview | null>(null)
|
||||
const restoring = ref(false)
|
||||
const targetVersion = ref<number | null>(null)
|
||||
|
||||
const actionLabel = (action: string) => historyActionLabel(action)
|
||||
const formatDate = (date: string) => formatHistoryDate(date)
|
||||
|
||||
const formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
|
||||
const label = props.fieldLabels[field] || field
|
||||
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
|
||||
const val = change.to ?? change.from
|
||||
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
|
||||
return `${label}: ${(val as Record<string, unknown>).name}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
targetVersion.value = version
|
||||
previewData.value = null
|
||||
modalVisible.value = true
|
||||
previewData.value = await fetchPreview(version)
|
||||
}
|
||||
|
||||
const confirmRestore = async () => {
|
||||
if (!targetVersion.value) return
|
||||
restoring.value = true
|
||||
const result = await restore(targetVersion.value)
|
||||
restoring.value = false
|
||||
if (result?.success) {
|
||||
modalVisible.value = false
|
||||
await fetchVersions()
|
||||
emit('restored')
|
||||
}
|
||||
else {
|
||||
error.value = 'La restauration a echoue.'
|
||||
modalVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVersions()
|
||||
})
|
||||
|
||||
// Auto-refresh when parent signals a data change
|
||||
watch(toRef(props, 'refreshKey'), () => {
|
||||
fetchVersions()
|
||||
})
|
||||
</script>
|
||||
128
frontend/app/components/common/Pagination.vue
Normal file
128
frontend/app/components/common/Pagination.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<IconLucideChevronFirst class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<IconLucideChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<IconLucideChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<IconLucideChevronLast class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
|
||||
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
maxVisiblePages: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage'])
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const total = props.totalPages
|
||||
const current = props.currentPage
|
||||
const maxVisible = props.maxVisiblePages
|
||||
|
||||
if (total <= maxVisible + 2) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
const half = Math.floor(maxVisible / 2)
|
||||
let start = Math.max(2, current - half)
|
||||
let end = Math.min(total - 1, current + half)
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= half + 1) {
|
||||
end = maxVisible
|
||||
}
|
||||
// Adjust if near end
|
||||
if (current >= total - half) {
|
||||
start = total - maxVisible + 1
|
||||
}
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis-start')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total - 1) {
|
||||
pages.push('ellipsis-end')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||
emit('update:currentPage', page)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
406
frontend/app/components/common/RequirementListEditor.vue
Normal file
406
frontend/app/components/common/RequirementListEditor.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<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-base-content/50">
|
||||
{{ labels.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
|
||||
{{ labels.emptyState }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(requirement, index) in requirements"
|
||||
:key="requirement.id || index"
|
||||
class="relative border border-base-200 rounded-lg p-4 pl-12 space-y-3 transition-colors"
|
||||
:class="requirementReorderClass(index)"
|
||||
@dragenter="onRequirementDragEnter(index)"
|
||||
@dragover="onRequirementDragOver"
|
||||
@drop="onRequirementDrop(index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-3 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="Réorganiser"
|
||||
@dragstart="onRequirementDragStart(index, $event)"
|
||||
@dragend="onRequirementDragEnd"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<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>
|
||||
<SearchSelect
|
||||
:model-value="normalizeTypeModel(requirement[typeField])"
|
||||
:options="typeOptions"
|
||||
:loading="typeLoading"
|
||||
size="sm"
|
||||
:placeholder="labels.typePlaceholder"
|
||||
:empty-text="typeOptions.length ? 'Aucun résultat' : 'Aucune option disponible'"
|
||||
:option-label="optionLabel"
|
||||
:option-description="optionDescription"
|
||||
@update:modelValue="(value) => updateRequirement(index, { [typeField]: normalizeTypeValue(value) })"
|
||||
/>
|
||||
</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="handleLabelInput(index, $event)"
|
||||
/>
|
||||
</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="handleMinInput(index, $event)"
|
||||
/>
|
||||
</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="handleMaxInput(index, $event)"
|
||||
/>
|
||||
</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="handleRequiredChange(index, $event)"
|
||||
/>
|
||||
{{ labels.requiredLabel }}
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
|
||||
@change="handleAllowNewModelsChange(index, $event)"
|
||||
/>
|
||||
{{ labels.allowNewModelsLabel }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
|
||||
type Option = {
|
||||
id: string | number
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
orderIndex?: number | 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,
|
||||
},
|
||||
typeLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const optionLabel = (option: Option) => {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
return option.name || ''
|
||||
}
|
||||
|
||||
const optionDescription = (option: Option) => {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
if (typeof option.description === 'string' && option.description.trim()) {
|
||||
return option.description.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const applyOrderIndex = (list: Requirement[]): Requirement[] =>
|
||||
list.map((item, index) => ({
|
||||
...item,
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
const addRequirement = () => {
|
||||
requirements.value = applyOrderIndex([
|
||||
...requirements.value,
|
||||
props.defaultRequirement(),
|
||||
])
|
||||
}
|
||||
|
||||
const removeRequirement = (index: number) => {
|
||||
requirements.value = applyOrderIndex(
|
||||
requirements.value.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
|
||||
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
|
||||
requirements.value = applyOrderIndex(
|
||||
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
|
||||
}
|
||||
|
||||
// Type-safe event handlers
|
||||
const getInputValue = (event: Event): string => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.value ?? ''
|
||||
}
|
||||
|
||||
const getCheckboxValue = (event: Event): boolean => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.checked ?? false
|
||||
}
|
||||
|
||||
const handleLabelInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { label: getInputValue(event) })
|
||||
}
|
||||
|
||||
const handleMinInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { minCount: parseNumber(getInputValue(event)) })
|
||||
}
|
||||
|
||||
const handleMaxInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { maxCount: parseOptionalNumber(getInputValue(event)) })
|
||||
}
|
||||
|
||||
const handleRequiredChange = (index: number, event: Event) => {
|
||||
updateRequirement(index, { required: getCheckboxValue(event) })
|
||||
}
|
||||
|
||||
const handleAllowNewModelsChange = (index: number, event: Event) => {
|
||||
updateRequirement(index, { allowNewModels: getCheckboxValue(event) })
|
||||
}
|
||||
|
||||
const draggingRequirementIndex = ref<number | null>(null)
|
||||
const requirementDropTargetIndex = ref<number | null>(null)
|
||||
|
||||
const resetRequirementDragState = () => {
|
||||
draggingRequirementIndex.value = null
|
||||
requirementDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const reorderRequirements = (from: number, to: number) => {
|
||||
const list = requirements.value
|
||||
if (!Array.isArray(list)) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
const updated = list.slice() as Requirement[]
|
||||
const [moved] = updated.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
updated.splice(to, 0, moved)
|
||||
requirements.value = applyOrderIndex(updated)
|
||||
resetRequirementDragState()
|
||||
}
|
||||
|
||||
const onRequirementDragStart = (index: number, event: DragEvent) => {
|
||||
draggingRequirementIndex.value = index
|
||||
requirementDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onRequirementDragEnter = (index: number) => {
|
||||
if (draggingRequirementIndex.value === null) {
|
||||
return
|
||||
}
|
||||
requirementDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onRequirementDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onRequirementDrop = (index: number) => {
|
||||
if (draggingRequirementIndex.value === null) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
reorderRequirements(draggingRequirementIndex.value, index)
|
||||
}
|
||||
|
||||
const onRequirementDragEnd = () => {
|
||||
resetRequirementDragState()
|
||||
}
|
||||
|
||||
const requirementReorderClass = (index: number) => {
|
||||
if (draggingRequirementIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingRequirementIndex.value !== null &&
|
||||
requirementDropTargetIndex.value === index &&
|
||||
draggingRequirementIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeTypeModel = (value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeTypeValue = (value: string | number | null | undefined) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
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.
|
||||
-->
|
||||
362
frontend/app/components/common/SearchSelect.vue
Normal file
362
frontend/app/components/common/SearchSelect.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="space-y-1 search-select">
|
||||
<label v-if="$slots.label" class="label">
|
||||
<span class="label-text">
|
||||
<slot name="label" />
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:class="inputClasses"
|
||||
@focus="handleFocus"
|
||||
@keydown.down.prevent="highlightNext"
|
||||
@keydown.up.prevent="highlightPrevious"
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
@input="handleInput"
|
||||
>
|
||||
<button
|
||||
v-if="clearable && modelValue"
|
||||
type="button"
|
||||
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
|
||||
aria-label="Effacer la sélection"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="toggleButtonClasses"
|
||||
@click="toggleDropdown"
|
||||
aria-label="Afficher les options"
|
||||
>
|
||||
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="openDropdown"
|
||||
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
|
||||
<span class="loading loading-spinner loading-xs" />
|
||||
Recherche en cours…
|
||||
</div>
|
||||
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
<ul v-else class="flex flex-col">
|
||||
<li
|
||||
v-for="(option, index) in displayedOptions"
|
||||
:key="resolveValue(option) ?? index"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start gap-1 px-3 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
:class="{
|
||||
'bg-base-200': isOptionSelected(option),
|
||||
'bg-base-300/60': highlightedIndex === index
|
||||
}"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
@mouseleave="highlightedIndex = -1"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="font-medium text-sm">
|
||||
<slot name="option-label" :option="option">
|
||||
{{ resolveLabel(option) }}
|
||||
</slot>
|
||||
</span>
|
||||
<span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
|
||||
<slot name="option-description" :option="option">
|
||||
{{ resolveDescription(option) }}
|
||||
</slot>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher…'
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: 'Aucun résultat'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
optionValue: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
optionLabel: {
|
||||
type: [String, Function],
|
||||
default: 'name'
|
||||
},
|
||||
optionDescription: {
|
||||
type: [String, Function],
|
||||
default: null
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
|
||||
},
|
||||
maxVisible: {
|
||||
type: Number,
|
||||
default: 50
|
||||
},
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const inputRef = ref(null)
|
||||
|
||||
const baseOptions = computed(() => Array.isArray(props.options) ? props.options : [])
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return baseOptions.value.find(option => isEqualValue(resolveValue(option), props.modelValue)) || null
|
||||
})
|
||||
|
||||
const displayedOptions = computed(() => {
|
||||
const items = baseOptions.value.slice()
|
||||
|
||||
const filtered = (!props.serverSearch && searchTerm.value.trim())
|
||||
? items.filter((option) => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
const description = resolveDescription(option)?.toLowerCase() || ''
|
||||
return label.includes(term) || description.includes(term)
|
||||
})
|
||||
: items
|
||||
|
||||
if (props.maxVisible && filtered.length > props.maxVisible) {
|
||||
return filtered.slice(0, props.maxVisible)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||
if (props.size === 'xs') base.push('input-xs')
|
||||
if (props.size === 'sm') base.push('input-sm')
|
||||
if (props.size === 'lg') base.push('input-lg')
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
const toggleButtonClasses = computed(() => {
|
||||
const base = ['absolute', 'top-1/2', '-translate-y-1/2', 'right-2', 'btn', 'btn-ghost']
|
||||
if (props.size === 'xs' || props.size === 'sm') {
|
||||
base.push('btn-xs')
|
||||
} else {
|
||||
base.push('btn-sm')
|
||||
}
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
(_newOptions) => {
|
||||
if (!openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(openDropdown, (isOpen) => {
|
||||
if (isOpen) {
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
})
|
||||
|
||||
function resolveValue (option) {
|
||||
if (!option) {
|
||||
return null
|
||||
}
|
||||
if (typeof props.optionValue === 'function') {
|
||||
return props.optionValue(option)
|
||||
}
|
||||
return option[props.optionValue]
|
||||
}
|
||||
|
||||
function resolveLabel (option) {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
if (typeof props.optionLabel === 'function') {
|
||||
return props.optionLabel(option) || ''
|
||||
}
|
||||
return option[props.optionLabel] || ''
|
||||
}
|
||||
|
||||
function resolveDescription (option) {
|
||||
if (!option || !props.optionDescription) {
|
||||
return ''
|
||||
}
|
||||
if (typeof props.optionDescription === 'function') {
|
||||
return props.optionDescription(option) || ''
|
||||
}
|
||||
return option[props.optionDescription] || ''
|
||||
}
|
||||
|
||||
function isEqualValue (a, b) {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
return String(a ?? '') === String(b ?? '')
|
||||
}
|
||||
|
||||
function isOptionSelected (option) {
|
||||
return isEqualValue(resolveValue(option), props.modelValue)
|
||||
}
|
||||
|
||||
function selectOption (option) {
|
||||
emit('update:modelValue', resolveValue(option) ?? '')
|
||||
searchTerm.value = resolveLabel(option)
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function handleFocus () {
|
||||
openDropdown.value = true
|
||||
if (searchTerm.value === '' && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDropdown () {
|
||||
openDropdown.value = !openDropdown.value
|
||||
if (openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
if (openDropdown.value && inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
function clearSelection () {
|
||||
emit('update:modelValue', '')
|
||||
searchTerm.value = ''
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
function highlightNext () {
|
||||
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % displayedOptions.value.length
|
||||
}
|
||||
|
||||
function highlightPrevious () {
|
||||
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
highlightedIndex.value =
|
||||
highlightedIndex.value <= 0
|
||||
? displayedOptions.value.length - 1
|
||||
: highlightedIndex.value - 1
|
||||
}
|
||||
|
||||
function selectHighlighted () {
|
||||
if (!openDropdown.value) {
|
||||
return
|
||||
}
|
||||
if (highlightedIndex.value >= 0 && highlightedIndex.value < displayedOptions.value.length) {
|
||||
selectOption(displayedOptions.value[highlightedIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalClick = (event) => {
|
||||
if (!openDropdown.value) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (target?.closest?.('.search-select')) {
|
||||
return
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleGlobalClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
162
frontend/app/components/common/StructureSkeletonPreview.vue
Normal file
162
frontend/app/components/common/StructureSkeletonPreview.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ previewBadge }}</span>
|
||||
</div>
|
||||
|
||||
<details v-if="structure" class="collapse collapse-arrow bg-base-100">
|
||||
<summary class="collapse-title text-sm font-medium">
|
||||
Consulter le détail du squelette
|
||||
</summary>
|
||||
<div class="collapse-content text-sm text-base-content/80" :class="variant === 'component' ? 'space-y-4' : 'space-y-2'">
|
||||
<!-- Custom fields: component variant (rich display) -->
|
||||
<div v-if="variant === 'component' && customFields.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="field in customFields"
|
||||
:key="field.customFieldId || field.id || field.name"
|
||||
class="rounded bg-base-200/60 px-3 py-2"
|
||||
>
|
||||
<p class="font-medium text-sm text-base-content">
|
||||
{{ field.name || field.key }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/70 mt-1">
|
||||
Type : {{ field.type || 'text' }}<span v-if="field.required"> • Obligatoire</span>
|
||||
<span v-if="Array.isArray(field.options) && field.options.length">
|
||||
• Options : {{ field.options.join(', ') }}
|
||||
</span>
|
||||
<span v-if="field.defaultValue">
|
||||
• Défaut : {{ field.defaultValue }}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Custom fields: piece variant (simple display) -->
|
||||
<div v-if="variant === 'piece' && customFields.length" class="space-y-1">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="field in customFields" :key="field.name">
|
||||
<span class="font-medium">{{ field.name }}</span>
|
||||
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Pieces: component variant only -->
|
||||
<div v-if="variant === 'component' && pieces.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(piece, index) in pieces"
|
||||
:key="piece.role || piece.typePieceId || piece.familyCode || index"
|
||||
>
|
||||
{{ resolvePieceLabelFn(piece) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Products: component variant only -->
|
||||
<div v-if="variant === 'component' && products.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(product, index) in products"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabelFn(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Subcomponents: component variant only -->
|
||||
<div v-if="variant === 'component' && subcomponents.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(subcomponent, index) in subcomponents"
|
||||
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
|
||||
>
|
||||
{{ resolveSubcomponentLabelFn(subcomponent) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: component variant -->
|
||||
<p
|
||||
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||
</p>
|
||||
|
||||
<!-- Empty state: piece variant -->
|
||||
<p v-if="variant === 'piece' && !customFields.length" class="text-xs text-base-content/70">
|
||||
Ce squelette ne définit pas encore de champs personnalisés.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getStructureCustomFields,
|
||||
getStructurePieces,
|
||||
getStructureProducts,
|
||||
getStructureSubcomponents,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
structure: Record<string, any> | null
|
||||
description?: string
|
||||
previewBadge: string
|
||||
variant: 'component' | 'piece'
|
||||
showEmptyState?: boolean
|
||||
resolvePieceLabel?: (piece: Record<string, any>) => string
|
||||
resolveProductLabel?: (product: Record<string, any>) => string
|
||||
resolveSubcomponentLabel?: (subcomponent: Record<string, any>) => string
|
||||
}>(), {
|
||||
description: '',
|
||||
showEmptyState: false,
|
||||
resolvePieceLabel: undefined,
|
||||
resolveProductLabel: undefined,
|
||||
resolveSubcomponentLabel: undefined,
|
||||
})
|
||||
|
||||
const customFields = computed(() =>
|
||||
getStructureCustomFields(props.structure),
|
||||
)
|
||||
|
||||
const pieces = computed(() =>
|
||||
props.variant === 'component' ? getStructurePieces(props.structure) : [],
|
||||
)
|
||||
|
||||
const products = computed(() =>
|
||||
props.variant === 'component' ? getStructureProducts(props.structure) : [],
|
||||
)
|
||||
|
||||
const subcomponents = computed(() =>
|
||||
props.variant === 'component' ? getStructureSubcomponents(props.structure) : [],
|
||||
)
|
||||
|
||||
const fallbackLabel = (item: Record<string, any>) =>
|
||||
item?.name || item?.label || item?.role || item?.alias || 'N/A'
|
||||
|
||||
const resolvePieceLabelFn = (piece: Record<string, any>) =>
|
||||
props.resolvePieceLabel ? props.resolvePieceLabel(piece) : fallbackLabel(piece)
|
||||
|
||||
const resolveProductLabelFn = (product: Record<string, any>) =>
|
||||
props.resolveProductLabel ? props.resolveProductLabel(product) : fallbackLabel(product)
|
||||
|
||||
const resolveSubcomponentLabelFn = (subcomponent: Record<string, any>) =>
|
||||
props.resolveSubcomponentLabel ? props.resolveSubcomponentLabel(subcomponent) : fallbackLabel(subcomponent)
|
||||
</script>
|
||||
198
frontend/app/components/common/VersionRestoreModal.vue
Normal file
198
frontend/app/components/common/VersionRestoreModal.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
|
||||
<div class="modal-box max-w-lg">
|
||||
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
|
||||
|
||||
<div v-if="!preview" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Restore mode explanation -->
|
||||
<div
|
||||
class="alert text-sm"
|
||||
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- FULL MODE -->
|
||||
<template v-if="preview.restoreMode === 'full'">
|
||||
<span class="font-semibold">Restauration complete</span>
|
||||
|
||||
<!-- Machine: always full, no category -->
|
||||
<template v-if="entityType === 'machine'">
|
||||
<span>Tous les elements de la machine seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix</li>
|
||||
<li>Site</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Composants, pieces et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-else-if="entityType === 'composant'">
|
||||
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Structure : pieces, sous-composants et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- PARTIAL MODE (never for machines) -->
|
||||
<template v-else>
|
||||
<span class="font-semibold">Restauration partielle</span>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-if="entityType === 'composant'">
|
||||
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Produits lies actuels</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff -->
|
||||
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
|
||||
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<li
|
||||
v-for="(change, field) in preview.diff"
|
||||
:key="field"
|
||||
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
|
||||
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
|
||||
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm text-base-content/60">
|
||||
Aucune difference detectee — l'entite est deja dans l'etat de cette version.
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="preview.warnings.length" class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="(warning, i) in preview.warnings"
|
||||
:key="i"
|
||||
class="alert alert-warning py-2 text-xs"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
|
||||
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
|
||||
Confirmer la restauration
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RestorePreview } from '~/composables/useEntityVersions'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
preview: RestorePreview | null
|
||||
restoring: boolean
|
||||
fieldLabels: Record<string, string>
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const formatFieldLabel = (field: string): string => {
|
||||
if (field.startsWith('customField:')) {
|
||||
return `Champ perso : ${field.replace('customField:', '')}`
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user