Compare commits
9 Commits
55739fe50f
...
v1.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d15c23a4 | ||
|
|
a7101c7e77 | ||
|
|
adccfa9b46 | ||
|
|
5f54acdfac | ||
|
|
94239031d6 | ||
|
|
b27662d2bc | ||
|
|
86d15faa01 | ||
|
|
603c03ca00 | ||
|
|
155cd9b358 |
@@ -29,10 +29,65 @@
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ relatedModalTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ relatedModalSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
||||
{{ relatedError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="relatedItems.length === 0"
|
||||
class="px-4 py-6 text-sm text-base-content/60"
|
||||
>
|
||||
Aucun élément lié à cette catégorie.
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||
<li
|
||||
v-for="entry in relatedItems"
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="openRelatedEdit(entry)"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeRelatedModal">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -41,6 +96,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
@@ -82,6 +138,7 @@ let activeController: AbortController | null = null;
|
||||
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { get } = useApi();
|
||||
|
||||
const headingText = computed(() => props.heading);
|
||||
const descriptionText = computed(
|
||||
@@ -257,6 +314,165 @@ const confirmDelete = async (item: ModelType) => {
|
||||
}
|
||||
};
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
reference?: string | null;
|
||||
};
|
||||
|
||||
const relatedModalOpen = ref(false);
|
||||
const relatedLoading = ref(false);
|
||||
const relatedError = ref<string | null>(null);
|
||||
const relatedItems = ref<RelatedEntry[]>([]);
|
||||
const relatedType = ref<ModelType | null>(null);
|
||||
|
||||
const relatedCategoryLabels: Record<
|
||||
ModelCategory,
|
||||
{ plural: string; singular: string }
|
||||
> = {
|
||||
COMPONENT: { plural: "composants", singular: "composant" },
|
||||
PIECE: { plural: "pièces", singular: "pièce" },
|
||||
PRODUCT: { plural: "produits", singular: "produit" },
|
||||
};
|
||||
|
||||
const relatedModalTitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "Éléments liés";
|
||||
}
|
||||
return `Éléments liés à « ${current.name} »`;
|
||||
});
|
||||
|
||||
const relatedModalSubtitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "";
|
||||
}
|
||||
const labels =
|
||||
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
||||
const count = relatedItems.value.length;
|
||||
if (relatedLoading.value) {
|
||||
return `Chargement des ${labels.plural}…`;
|
||||
}
|
||||
if (count === 0) {
|
||||
return `Aucun ${labels.singular} lié.`;
|
||||
}
|
||||
if (count === 1) {
|
||||
return `1 ${labels.singular} lié.`;
|
||||
}
|
||||
return `${count} ${labels.plural} liés.`;
|
||||
});
|
||||
|
||||
const extractCollection = (payload: any): any[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member;
|
||||
}
|
||||
if (Array.isArray(payload?.["hydra:member"])) {
|
||||
return payload["hydra:member"];
|
||||
}
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return { endpoint: "/composants", filterKey: "typeComposant" };
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return { endpoint: "/pieces", filterKey: "typePiece" };
|
||||
}
|
||||
return { endpoint: "/products", filterKey: "typeProduct" };
|
||||
};
|
||||
|
||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/pieces";
|
||||
}
|
||||
return "/product";
|
||||
};
|
||||
|
||||
const mapRelatedEntry = (item: any): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object" || typeof item.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof item.name === "string" && item.name.trim()
|
||||
? item.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof item.reference === "string" && item.reference.trim()
|
||||
? item.reference
|
||||
: typeof item.code === "string" && item.code.trim()
|
||||
? item.code
|
||||
: null;
|
||||
return {
|
||||
id: item.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRelatedItems = async (item: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
||||
const params = new URLSearchParams();
|
||||
params.set("itemsPerPage", "200");
|
||||
params.set(filterKey, buildModelTypeIri(item.id));
|
||||
params.set("order[name]", "asc");
|
||||
|
||||
relatedLoading.value = true;
|
||||
relatedError.value = null;
|
||||
relatedItems.value = [];
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`);
|
||||
if (!result.success) {
|
||||
relatedError.value =
|
||||
result.error ?? "Impossible de charger les éléments liés.";
|
||||
return;
|
||||
}
|
||||
const collection = extractCollection(result.data);
|
||||
relatedItems.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
||||
} catch (error) {
|
||||
relatedError.value = extractErrorMessage(error);
|
||||
} finally {
|
||||
relatedLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openRelatedModal = (item: ModelType) => {
|
||||
relatedType.value = item;
|
||||
relatedModalOpen.value = true;
|
||||
void loadRelatedItems(item);
|
||||
};
|
||||
|
||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const basePath = resolveRelatedEditBasePath(current.category);
|
||||
relatedModalOpen.value = false;
|
||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la fiche d'édition.");
|
||||
});
|
||||
};
|
||||
|
||||
const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -108,6 +108,15 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="disableSubmit"
|
||||
class="alert alert-warning"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span>{{ disableSubmitMessage }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
@@ -150,6 +159,8 @@ const props = withDefaults(defineProps<{
|
||||
structureLoading?: boolean
|
||||
allowComponentSubcomponents?: boolean
|
||||
componentSubcomponentMaxDepth?: number
|
||||
disableSubmit?: boolean
|
||||
disableSubmitMessage?: string
|
||||
}>(), {
|
||||
initialData: null,
|
||||
saving: false,
|
||||
@@ -157,6 +168,8 @@ const props = withDefaults(defineProps<{
|
||||
structureLoading: false,
|
||||
allowComponentSubcomponents: true,
|
||||
componentSubcomponentMaxDepth: 1,
|
||||
disableSubmit: false,
|
||||
disableSubmitMessage: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -173,6 +186,12 @@ const componentSubcomponentMaxDepth = computed(() =>
|
||||
? props.componentSubcomponentMaxDepth
|
||||
: 1,
|
||||
)
|
||||
const disableSubmit = computed(() => props.disableSubmit === true)
|
||||
const disableSubmitMessage = computed(() =>
|
||||
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
|
||||
? props.disableSubmitMessage
|
||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||
)
|
||||
|
||||
const form = reactive<ModelTypePayload>({
|
||||
name: '',
|
||||
@@ -248,7 +267,7 @@ const resetForm = () => {
|
||||
}
|
||||
|
||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
||||
|
||||
const validate = () => {
|
||||
errors.name = undefined
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<tr class="text-base-content/70">
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th scope="col" class="w-32 text-right">Actions</th>
|
||||
<th scope="col" class="w-48 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,6 +45,9 @@
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -72,6 +75,9 @@
|
||||
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -123,6 +129,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'related', item: ModelType): void;
|
||||
(e: 'edit', item: ModelType): void;
|
||||
(e: 'delete', item: ModelType): void;
|
||||
(e: 'update:offset', offset: number): void;
|
||||
|
||||
101
app/composables/useCategoryEditGuard.ts
Normal file
101
app/composables/useCategoryEditGuard.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
type GuardLabels = {
|
||||
singular: string
|
||||
plural: string
|
||||
verifying: string
|
||||
}
|
||||
|
||||
type GuardConfig = {
|
||||
endpoint: string
|
||||
filterKey: string
|
||||
labels: GuardLabels
|
||||
}
|
||||
|
||||
const extractTotal = (payload: any, fallbackLength: number) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member.length
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member'].length
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useCategoryEditGuard (config: GuardConfig) {
|
||||
const { get } = useApi()
|
||||
const { showError } = useToast()
|
||||
|
||||
const linkedCount = ref(0)
|
||||
const linkedLoading = ref(false)
|
||||
|
||||
const loadLinkedCount = async (modelTypeId: string) => {
|
||||
linkedLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '1')
|
||||
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
|
||||
|
||||
const result = await get(`${config.endpoint}?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
linkedCount.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackLength = Array.isArray(result.data?.member)
|
||||
? result.data.member.length
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member'].length
|
||||
: 0
|
||||
|
||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||
} catch (error) {
|
||||
linkedCount.value = 0
|
||||
} finally {
|
||||
linkedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitBlocked = computed(
|
||||
() => linkedLoading.value || linkedCount.value > 0,
|
||||
)
|
||||
|
||||
const submitBlockMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
if (linkedCount.value <= 0) {
|
||||
return ''
|
||||
}
|
||||
if (linkedCount.value === 1) {
|
||||
return `Modification bloquée : 1 ${config.labels.singular} est déjà lié à cette catégorie.`
|
||||
}
|
||||
return `Modification bloquée : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie.`
|
||||
})
|
||||
|
||||
const guardSubmitOrNotify = () => {
|
||||
if (!isSubmitBlocked.value) {
|
||||
return false
|
||||
}
|
||||
showError(submitBlockMessage.value || 'Modification bloquée pour cette catégorie.')
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
linkedCount,
|
||||
linkedLoading,
|
||||
isSubmitBlocked,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
}
|
||||
}
|
||||
|
||||
67
app/composables/useComponentHistory.ts
Normal file
67
app/composables/useComponentHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ComponentHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ComponentHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ComponentHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): ComponentHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useComponentHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<ComponentHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (componentId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/composants/${componentId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as ComponentHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
67
app/composables/usePieceHistory.ts
Normal file
67
app/composables/usePieceHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type PieceHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type PieceHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: PieceHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): PieceHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function usePieceHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<PieceHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (pieceId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/pieces/${pieceId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as PieceHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ export function usePieces () {
|
||||
piece.productId = productId
|
||||
}
|
||||
}
|
||||
const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
|
||||
if (productIds.length === 0 && piece.productId) {
|
||||
piece.productIds = [piece.productId]
|
||||
} else if (productIds.length > 0) {
|
||||
piece.productIds = productIds.map((id) => String(id))
|
||||
if (!piece.productId) {
|
||||
piece.productId = piece.productIds[0] || null
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
piece.constructeurIds,
|
||||
piece.constructeurs,
|
||||
|
||||
67
app/composables/useProductHistory.ts
Normal file
67
app/composables/useProductHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ProductHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ProductHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ProductHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): ProductHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useProductHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<ProductHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (productId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/products/${productId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as ProductHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isSubmitBlocked,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/composants',
|
||||
filterKey: 'typeComposant',
|
||||
labels: {
|
||||
singular: 'composant',
|
||||
plural: 'composants',
|
||||
verifying: 'Vérification des composants liés en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name
|
||||
? `Modifier « ${initialData.value.name} »`
|
||||
@@ -88,6 +106,8 @@ const loadCategory = async () => {
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -101,6 +121,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -176,6 +176,18 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureProducts(selectedTypeStructure).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 getStructureProducts(selectedTypeStructure)"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabel(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).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">
|
||||
@@ -189,7 +201,7 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
|
||||
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureProducts(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||
@@ -198,6 +210,50 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="structureSelections.hasAny"
|
||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
||||
>
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Sélections actuelles</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div v-if="structureSelections.pieces.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
|
||||
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="structureSelections.products.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
|
||||
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="structureSelections.components.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
|
||||
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||
@@ -378,6 +434,74 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" 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="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.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 historyEntries"
|
||||
: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="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(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>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -401,12 +525,16 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
@@ -436,11 +564,20 @@ const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updateComposant } = useComposants()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useComponentHistory()
|
||||
|
||||
const component = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -451,6 +588,88 @@ const loadingDocuments = ref(false)
|
||||
const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
structure: 'Structure',
|
||||
typeComposant: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ComponentHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
@@ -512,6 +731,36 @@ const pieceTypeLabelMap = computed(() => ({
|
||||
),
|
||||
...fetchedPieceTypeMap.value,
|
||||
}))
|
||||
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||
const productTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(productTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedProductTypeMap.value,
|
||||
}))
|
||||
const pieceCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(pieces.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const productCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(products.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const componentCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(componentCatalogRef.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const documentThumbnailClass = (document: any) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
@@ -664,6 +913,7 @@ const fetchComponent = async () => {
|
||||
component.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadHistory(result.data.id)
|
||||
} else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
@@ -1018,6 +1268,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
@@ -1026,6 +1280,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
|
||||
return Array.isArray(legacy) ? legacy : []
|
||||
}
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (piece.role) {
|
||||
@@ -1069,16 +1326,65 @@ const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
fetchedPieceTypeMap.value = next
|
||||
}
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (product.role) {
|
||||
parts.push(product.role)
|
||||
}
|
||||
if (product.typeProduct?.name) {
|
||||
parts.push(product.typeProduct.name)
|
||||
} else if (product.typeProductLabel) {
|
||||
parts.push(product.typeProductLabel)
|
||||
} else if (product.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
|
||||
parts.push(productTypeLabelMap.value[product.typeProductId])
|
||||
} else if (product.typeProduct?.code) {
|
||||
parts.push(`Catégorie ${product.typeProduct.code}`)
|
||||
} else if (product.familyCode) {
|
||||
parts.push(`Catégorie ${product.familyCode}`)
|
||||
} else if (product.typeProductId) {
|
||||
parts.push(`#${product.typeProductId}`)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Produit'
|
||||
}
|
||||
|
||||
const fetchProductTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedProductTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
}
|
||||
})
|
||||
fetchedProductTypeMap.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const ids = getStructurePieces(structure)
|
||||
const pieceIds = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (!ids.length) {
|
||||
return
|
||||
if (pieceIds.length) {
|
||||
fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
|
||||
}
|
||||
|
||||
const productIds = getStructureProducts(structure)
|
||||
.map((product: any) => product?.typeProductId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (productIds.length) {
|
||||
fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
|
||||
}
|
||||
fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
@@ -1109,6 +1415,104 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||
return parts.length ? parts.join(' • ') : 'Sous-composant'
|
||||
}
|
||||
|
||||
type SelectionEntry = {
|
||||
id: string
|
||||
path: string
|
||||
requirementLabel: string
|
||||
resolvedName: string
|
||||
}
|
||||
|
||||
const collectStructureSelections = (root: any): {
|
||||
pieces: SelectionEntry[]
|
||||
products: SelectionEntry[]
|
||||
components: SelectionEntry[]
|
||||
} => {
|
||||
const piecesSelected: SelectionEntry[] = []
|
||||
const productsSelected: SelectionEntry[] = []
|
||||
const componentsSelected: SelectionEntry[] = []
|
||||
|
||||
if (!root || typeof root !== 'object') {
|
||||
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||
}
|
||||
|
||||
const visitNode = (node: any, fallbackPath = 'racine') => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
|
||||
|
||||
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
|
||||
nodePieces.forEach((entry: any, index: number) => {
|
||||
const selectedId = entry?.selectedPieceId
|
||||
if (!isNonEmptyString(selectedId)) {
|
||||
return
|
||||
}
|
||||
const definition = entry?.definition ?? entry
|
||||
const catalogPiece = pieceCatalogMap.value.get(selectedId)
|
||||
piecesSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
|
||||
requirementLabel: resolvePieceLabel(definition),
|
||||
resolvedName: catalogPiece?.name || selectedId,
|
||||
})
|
||||
})
|
||||
|
||||
const nodeProducts = Array.isArray(node.products) ? node.products : []
|
||||
nodeProducts.forEach((entry: any, index: number) => {
|
||||
const selectedId = entry?.selectedProductId
|
||||
if (!isNonEmptyString(selectedId)) {
|
||||
return
|
||||
}
|
||||
const definition = entry?.definition ?? entry
|
||||
const catalogProduct = productCatalogMap.value.get(selectedId)
|
||||
productsSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
|
||||
requirementLabel: resolveProductLabel(definition),
|
||||
resolvedName: catalogProduct?.name || selectedId,
|
||||
})
|
||||
})
|
||||
|
||||
const nodeChildren = Array.isArray(node.subcomponents)
|
||||
? node.subcomponents
|
||||
: Array.isArray(node.subComponents)
|
||||
? node.subComponents
|
||||
: []
|
||||
|
||||
nodeChildren.forEach((child: any, index: number) => {
|
||||
const selectedId = child?.selectedComponentId
|
||||
if (isNonEmptyString(selectedId)) {
|
||||
const definition = child?.definition ?? child
|
||||
const catalogComponent = componentCatalogMap.value.get(selectedId)
|
||||
componentsSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
|
||||
requirementLabel: resolveSubcomponentLabel(definition),
|
||||
resolvedName: catalogComponent?.name || selectedId,
|
||||
})
|
||||
}
|
||||
|
||||
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
|
||||
})
|
||||
}
|
||||
|
||||
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
|
||||
|
||||
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||
}
|
||||
|
||||
const structureSelections = computed(() => {
|
||||
const selections = collectStructureSelections(component.value?.structure)
|
||||
const total =
|
||||
selections.pieces.length + selections.products.length + selections.components.length
|
||||
return {
|
||||
...selections,
|
||||
total,
|
||||
hasAny: total > 0,
|
||||
}
|
||||
})
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
@@ -1208,7 +1612,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadComponentTypes(), loadPieceTypes(), fetchComponent()])
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
loadPieces({ itemsPerPage: 500 }),
|
||||
loadProducts({ itemsPerPage: 500, force: true }),
|
||||
loadComposants({ itemsPerPage: 500 }),
|
||||
fetchComponent(),
|
||||
])
|
||||
loading.value = false
|
||||
if (component.value?.id) {
|
||||
await refreshDocuments()
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isSubmitBlocked,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/pieces',
|
||||
filterKey: 'typePiece',
|
||||
labels: {
|
||||
singular: 'pièce',
|
||||
plural: 'pièces',
|
||||
verifying: 'Vérification des pièces liées en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
|
||||
)
|
||||
@@ -86,6 +104,8 @@ const loadCategory = async () => {
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -99,6 +119,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -146,12 +146,26 @@
|
||||
<span>{{ description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ProductSelect
|
||||
v-model="editionForm.productId"
|
||||
:disabled="saving"
|
||||
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
||||
helper-text="Un produit valide est requis pour cette pièce."
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="entry in productRequirementEntries"
|
||||
:key="entry.key"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="productSelections[entry.index] || null"
|
||||
:disabled="saving"
|
||||
:type-product-id="entry.typeProductId"
|
||||
helper-text="Un produit valide est requis pour cette pièce."
|
||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType || resolvedStructure" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -367,6 +381,74 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" 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="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.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 historyEntries"
|
||||
: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="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(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>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -395,6 +477,7 @@ import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
@@ -430,6 +513,12 @@ const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEn
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = usePieceHistory()
|
||||
|
||||
const piece = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -441,6 +530,88 @@ const pieceDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
typePiece: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
productIds: 'Produits liés',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: PieceHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
const editionForm = reactive({
|
||||
@@ -448,8 +619,8 @@ const editionForm = reactive({
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
productId: null as string | null,
|
||||
})
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const documentIcon = (doc: any) =>
|
||||
@@ -592,14 +763,18 @@ const selectedType = computed(() => {
|
||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const structureProducts = computed(() =>
|
||||
getStructureProducts(resolvedStructure.value),
|
||||
)
|
||||
|
||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||
|
||||
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
||||
|
||||
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||
if (!requirement) {
|
||||
return `Produit ${index + 1}`
|
||||
@@ -628,6 +803,50 @@ const productRequirementDescriptions = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const ensureProductSelections = (count: number) => {
|
||||
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
let pendingProductIds: string[] = []
|
||||
|
||||
const productRequirementEntries = computed(() =>
|
||||
structureProducts.value.map((requirement, index) => ({
|
||||
index,
|
||||
key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
|
||||
label: describeProductRequirement(requirement, index),
|
||||
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||
})),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
!requiresProductSelection.value ||
|
||||
productRequirementEntries.value.every((entry) => {
|
||||
const value = productSelections.value[entry.index]
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}),
|
||||
)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
const normalized = typeof value === 'string' ? value : null
|
||||
const next = [...productSelections.value]
|
||||
next[index] = normalized
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
watch(structureProducts, (products) => {
|
||||
ensureProductSelections(products.length)
|
||||
if (!pendingProductIds.length || products.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = Array.from(
|
||||
{ length: products.length },
|
||||
(_, index) => pendingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
@@ -645,7 +864,7 @@ const canSubmit = computed(() =>
|
||||
piece.value &&
|
||||
editionForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
(!requiresProductSelection.value || editionForm.productId) &&
|
||||
productSelectionsFilled.value &&
|
||||
!saving.value,
|
||||
),
|
||||
)
|
||||
@@ -680,6 +899,7 @@ const fetchPiece = async () => {
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadPieceTypeDetails(result.data)
|
||||
await loadHistory(result.data.id)
|
||||
} else {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
@@ -730,11 +950,26 @@ watch(
|
||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
|
||||
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
||||
? currentPiece.productIds.map((id: unknown) => String(id))
|
||||
: currentPiece.product?.id || currentPiece.productId
|
||||
? [String(currentPiece.product?.id || currentPiece.productId)]
|
||||
: []
|
||||
pendingProductIds = existingProductIds
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
if (existingProductIds.length && structureProducts.value.length) {
|
||||
const next = Array.from(
|
||||
{ length: structureProducts.value.length },
|
||||
(_, index) => existingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
}
|
||||
|
||||
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
@@ -755,6 +990,7 @@ watch(resolvedStructure, (currentStructure) => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
@@ -763,7 +999,7 @@ const submitEdition = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductSelection.value && !editionForm.productId) {
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
@@ -784,11 +1020,13 @@ const submitEdition = async () => {
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference ? reference : null
|
||||
|
||||
const selectedProductId =
|
||||
typeof editionForm.productId === 'string'
|
||||
? editionForm.productId.trim()
|
||||
: ''
|
||||
payload.productId = selectedProductId || null
|
||||
const normalizedProductIds = productRequirementEntries.value
|
||||
.map((entry) => productSelections.value[entry.index])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
|
||||
payload.productIds = normalizedProductIds
|
||||
payload.productId = normalizedProductIds[0] || null
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
@@ -981,12 +1219,6 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
|
||||
@@ -118,12 +118,26 @@
|
||||
<span>{{ description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ProductSelect
|
||||
v-model="creationForm.productId"
|
||||
:disabled="submitting || !selectedType"
|
||||
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
||||
helper-text="Un produit est requis pour cette pièce."
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="entry in productRequirementEntries"
|
||||
:key="entry.key"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="productSelections[entry.index] || null"
|
||||
:disabled="submitting || !selectedType"
|
||||
:type-product-id="entry.typeProductId"
|
||||
helper-text="Un produit est requis pour cette pièce."
|
||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -317,8 +331,8 @@ const creationForm = reactive({
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
productId: null as string | null,
|
||||
})
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
@@ -364,14 +378,18 @@ const selectedType = computed(() => {
|
||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const structureProducts = computed(() =>
|
||||
getStructureProducts(selectedType.value?.structure ?? null),
|
||||
)
|
||||
|
||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||
|
||||
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
||||
|
||||
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||
if (!requirement) {
|
||||
return `Produit ${index + 1}`
|
||||
@@ -400,6 +418,39 @@ const productRequirementDescriptions = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const ensureProductSelections = (count: number) => {
|
||||
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
const productRequirementEntries = computed(() =>
|
||||
structureProducts.value.map((requirement, index) => ({
|
||||
index,
|
||||
key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
|
||||
label: describeProductRequirement(requirement, index),
|
||||
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||
})),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
!requiresProductSelection.value ||
|
||||
productRequirementEntries.value.every((entry) => {
|
||||
const value = productSelections.value[entry.index]
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}),
|
||||
)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
const normalized = typeof value === 'string' ? value : null
|
||||
const next = [...productSelections.value]
|
||||
next[index] = normalized
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
watch(structureProducts, (products) => {
|
||||
ensureProductSelections(products.length)
|
||||
})
|
||||
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
@@ -411,7 +462,7 @@ watch(selectedType, (type) => {
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||
creationForm.productId = null
|
||||
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
@@ -431,7 +482,7 @@ const canSubmit = computed(() =>
|
||||
selectedType.value &&
|
||||
creationForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
(!requiresProductSelection.value || creationForm.productId) &&
|
||||
productSelectionsFilled.value &&
|
||||
!submitting.value,
|
||||
),
|
||||
)
|
||||
@@ -449,18 +500,12 @@ const toFieldString = (value: unknown): string => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const clearCreationForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.reference = ''
|
||||
creationForm.constructeurIds = []
|
||||
creationForm.prix = ''
|
||||
creationForm.productId = null
|
||||
productSelections.value = []
|
||||
lastSuggestedName.value = ''
|
||||
}
|
||||
|
||||
@@ -470,7 +515,7 @@ const submitCreation = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductSelection.value && !creationForm.productId) {
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
@@ -487,12 +532,13 @@ const submitCreation = async () => {
|
||||
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
|
||||
const selectedProductId =
|
||||
typeof creationForm.productId === 'string'
|
||||
? creationForm.productId.trim()
|
||||
: ''
|
||||
if (selectedProductId) {
|
||||
payload.productId = selectedProductId
|
||||
const normalizedProductIds = productRequirementEntries.value
|
||||
.map((entry) => productSelections.value[entry.index])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
if (normalizedProductIds.length) {
|
||||
payload.productIds = normalizedProductIds
|
||||
payload.productId = normalizedProductIds[0]
|
||||
}
|
||||
|
||||
const rawPrice = typeof creationForm.prix === 'string'
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isSubmitBlocked,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/products',
|
||||
filterKey: 'typeProduct',
|
||||
labels: {
|
||||
singular: 'produit',
|
||||
plural: 'produits',
|
||||
verifying: 'Vérification des produits liés en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
|
||||
)
|
||||
@@ -86,6 +104,8 @@ const loadCategory = async () => {
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -99,6 +119,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -301,6 +301,74 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" 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="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.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 historyEntries"
|
||||
: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="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(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>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -329,6 +397,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
@@ -359,6 +428,12 @@ const {
|
||||
deleteDocument: deleteProductDocument,
|
||||
} = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useProductHistory()
|
||||
|
||||
const product = ref<any | null>(null)
|
||||
const productType = ref<any | null>(null)
|
||||
@@ -373,6 +448,86 @@ const productDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
supplierPrice: 'Prix fournisseur',
|
||||
typeProduct: 'Catégorie',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ProductHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ProductModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
@@ -509,6 +664,7 @@ const loadProduct = async () => {
|
||||
}
|
||||
await hydrateForm()
|
||||
await refreshDocuments()
|
||||
await loadHistory(result.data.id)
|
||||
} else {
|
||||
product.value = null
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
||||
const query: Record<string, string | number> = {};
|
||||
|
||||
if (params.q) {
|
||||
query.q = params.q;
|
||||
query.name = params.q;
|
||||
}
|
||||
if (params.category) {
|
||||
query.category = params.category;
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Lire la version depuis le fichier VERSION à la racine du projet parent
|
||||
const getAppVersion = (): string => {
|
||||
try {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const versionPath = resolve(__dirname, '..', 'VERSION')
|
||||
return readFileSync(versionPath, 'utf-8').trim()
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
const appVersion = process.env.NUXT_PUBLIC_APP_VERSION || getAppVersion()
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
|
||||
@@ -27,7 +44,7 @@ export default defineNuxtConfig({
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
|
||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
||||
appVersion: process.env.NUXT_PUBLIC_APP_VERSION || '0.1.0',
|
||||
appVersion: appVersion,
|
||||
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
|
||||
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
||||
|
||||
Reference in New Issue
Block a user