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"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
@update:offset="onOffsetChange"
|
@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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -41,6 +96,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from "#imports";
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
|
import { useApi } from "~/composables/useApi";
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
@@ -82,6 +138,7 @@ let activeController: AbortController | null = null;
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading);
|
||||||
const descriptionText = computed(
|
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(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -108,6 +108,15 @@
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</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">
|
<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')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -150,6 +159,8 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading?: boolean
|
structureLoading?: boolean
|
||||||
allowComponentSubcomponents?: boolean
|
allowComponentSubcomponents?: boolean
|
||||||
componentSubcomponentMaxDepth?: number
|
componentSubcomponentMaxDepth?: number
|
||||||
|
disableSubmit?: boolean
|
||||||
|
disableSubmitMessage?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -157,6 +168,8 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading: false,
|
structureLoading: false,
|
||||||
allowComponentSubcomponents: true,
|
allowComponentSubcomponents: true,
|
||||||
componentSubcomponentMaxDepth: 1,
|
componentSubcomponentMaxDepth: 1,
|
||||||
|
disableSubmit: false,
|
||||||
|
disableSubmitMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -173,6 +186,12 @@ const componentSubcomponentMaxDepth = computed(() =>
|
|||||||
? props.componentSubcomponentMaxDepth
|
? props.componentSubcomponentMaxDepth
|
||||||
: 1,
|
: 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>({
|
const form = reactive<ModelTypePayload>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -248,7 +267,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
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 = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<tr class="text-base-content/70">
|
<tr class="text-base-content/70">
|
||||||
<th scope="col">Nom</th>
|
<th scope="col">Nom</th>
|
||||||
<th scope="col">Notes</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -45,6 +45,9 @@
|
|||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right space-x-2">
|
<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)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</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/80" v-if="item.notes">{{ item.notes }}</p>
|
||||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de 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">
|
<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)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -123,6 +129,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
(e: 'related', item: ModelType): void;
|
||||||
(e: 'edit', item: ModelType): void;
|
(e: 'edit', item: ModelType): void;
|
||||||
(e: 'delete', item: ModelType): void;
|
(e: 'delete', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): 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
|
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(
|
const ids = uniqueConstructeurIds(
|
||||||
piece.constructeurIds,
|
piece.constructeurIds,
|
||||||
piece.constructeurs,
|
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"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
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(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name
|
initialData.value?.name
|
||||||
? `Modifier « ${initialData.value.name} »`
|
? `Modifier « ${initialData.value.name} »`
|
||||||
@@ -88,6 +106,8 @@ const loadCategory = async () => {
|
|||||||
notes: response.notes ?? response.description ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -101,6 +121,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -176,6 +176,18 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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">
|
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
||||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
@@ -189,7 +201,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p
|
<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"
|
class="text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||||
@@ -198,6 +210,50 @@
|
|||||||
</details>
|
</details>
|
||||||
</div>
|
</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">
|
<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">
|
<header class="space-y-1">
|
||||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||||
@@ -378,6 +434,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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 }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -401,12 +525,16 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
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 { useCustomFields } from '~/composables/useCustomFields'
|
||||||
import { useApi } from '~/composables/useApi'
|
import { useApi } from '~/composables/useApi'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
@@ -436,11 +564,20 @@ const router = useRouter()
|
|||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
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 { ensureConstructeurs } = useConstructeurs()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = useComponentHistory()
|
||||||
|
|
||||||
const component = ref<any | null>(null)
|
const component = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -451,6 +588,88 @@ const loadingDocuments = ref(false)
|
|||||||
const componentDocuments = ref<any[]>([])
|
const componentDocuments = ref<any[]>([])
|
||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
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 selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
@@ -512,6 +731,36 @@ const pieceTypeLabelMap = computed(() => ({
|
|||||||
),
|
),
|
||||||
...fetchedPieceTypeMap.value,
|
...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) => {
|
const documentThumbnailClass = (document: any) => {
|
||||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||||
return 'h-24 w-20'
|
return 'h-24 w-20'
|
||||||
@@ -664,6 +913,7 @@ const fetchComponent = async () => {
|
|||||||
component.value.customFieldValues = customValues.data
|
component.value.customFieldValues = customValues.data
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues.data)
|
||||||
}
|
}
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
component.value = null
|
component.value = null
|
||||||
componentDocuments.value = []
|
componentDocuments.value = []
|
||||||
@@ -1018,6 +1268,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
|||||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||||
|
return Array.isArray(structure?.products) ? structure.products : []
|
||||||
|
}
|
||||||
|
|
||||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||||
if (Array.isArray(structure?.subcomponents)) {
|
if (Array.isArray(structure?.subcomponents)) {
|
||||||
return structure.subcomponents
|
return structure.subcomponents
|
||||||
@@ -1026,6 +1280,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
|
|||||||
return Array.isArray(legacy) ? legacy : []
|
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 resolvePieceLabel = (piece: Record<string, any>) => {
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
if (piece.role) {
|
if (piece.role) {
|
||||||
@@ -1069,16 +1326,65 @@ const fetchPieceTypeNames = async (ids: string[]) => {
|
|||||||
fetchedPieceTypeMap.value = next
|
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(
|
watch(
|
||||||
selectedTypeStructure,
|
selectedTypeStructure,
|
||||||
(structure) => {
|
(structure) => {
|
||||||
const ids = getStructurePieces(structure)
|
const pieceIds = getStructurePieces(structure)
|
||||||
.map((piece: any) => piece?.typePieceId)
|
.map((piece: any) => piece?.typePieceId)
|
||||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||||
if (!ids.length) {
|
if (pieceIds.length) {
|
||||||
return
|
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 },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -1109,6 +1415,104 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
|||||||
return parts.length ? parts.join(' • ') : 'Sous-composant'
|
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) => ({
|
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||||
customFieldName: field.name,
|
customFieldName: field.name,
|
||||||
customFieldType: field.type,
|
customFieldType: field.type,
|
||||||
@@ -1208,7 +1612,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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
|
loading.value = false
|
||||||
if (component.value?.id) {
|
if (component.value?.id) {
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
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(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
|
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 ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -99,6 +119,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -146,12 +146,26 @@
|
|||||||
<span>{{ description }}</span>
|
<span>{{ description }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ProductSelect
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
v-model="editionForm.productId"
|
<div
|
||||||
:disabled="saving"
|
v-for="entry in productRequirementEntries"
|
||||||
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
:key="entry.key"
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
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>
|
||||||
|
|
||||||
<div v-if="selectedType || resolvedStructure" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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>
|
</p>
|
||||||
</div>
|
</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">
|
<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 }">
|
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -395,6 +477,7 @@ import { useApi } from '~/composables/useApi'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
@@ -430,6 +513,12 @@ const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEn
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = usePieceHistory()
|
||||||
|
|
||||||
const piece = ref<any | null>(null)
|
const piece = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -441,6 +530,88 @@ const pieceDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
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 selectedTypeId = ref<string>('')
|
||||||
const pieceTypeDetails = ref<any | null>(null)
|
const pieceTypeDetails = ref<any | null>(null)
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
@@ -448,8 +619,8 @@ const editionForm = reactive({
|
|||||||
reference: '' as string,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
productId: null as string | null,
|
|
||||||
})
|
})
|
||||||
|
const productSelections = ref<(string | null)[]>([])
|
||||||
|
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
const documentIcon = (doc: any) =>
|
const documentIcon = (doc: any) =>
|
||||||
@@ -592,14 +763,18 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
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(() =>
|
const structureProducts = computed(() =>
|
||||||
getStructureProducts(resolvedStructure.value),
|
getStructureProducts(resolvedStructure.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||||
|
|
||||||
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
|
||||||
|
|
||||||
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||||
if (!requirement) {
|
if (!requirement) {
|
||||||
return `Produit ${index + 1}`
|
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(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
customFieldInputs.value.every((field) => {
|
customFieldInputs.value.every((field) => {
|
||||||
if (!field.required) {
|
if (!field.required) {
|
||||||
@@ -645,7 +864,7 @@ const canSubmit = computed(() =>
|
|||||||
piece.value &&
|
piece.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
(!requiresProductSelection.value || editionForm.productId) &&
|
productSelectionsFilled.value &&
|
||||||
!saving.value,
|
!saving.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -680,6 +899,7 @@ const fetchPiece = async () => {
|
|||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues.data)
|
||||||
}
|
}
|
||||||
await loadPieceTypeDetails(result.data)
|
await loadPieceTypeDetails(result.data)
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
pieceDocuments.value = []
|
pieceDocuments.value = []
|
||||||
@@ -730,11 +950,26 @@ watch(
|
|||||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||||
)
|
)
|
||||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||||
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
|
|
||||||
if (editionForm.constructeurIds.length) {
|
if (editionForm.constructeurIds.length) {
|
||||||
void ensureConstructeurs(editionForm.constructeurIds)
|
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)
|
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
@@ -755,6 +990,7 @@ watch(resolvedStructure, (currentStructure) => {
|
|||||||
if (!piece.value) {
|
if (!piece.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ensureProductSelections(structureProducts.value.length)
|
||||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -763,7 +999,7 @@ const submitEdition = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresProductSelection.value && !editionForm.productId) {
|
if (!productSelectionsFilled.value) {
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -784,11 +1020,13 @@ const submitEdition = async () => {
|
|||||||
const reference = editionForm.reference.trim()
|
const reference = editionForm.reference.trim()
|
||||||
payload.reference = reference ? reference : null
|
payload.reference = reference ? reference : null
|
||||||
|
|
||||||
const selectedProductId =
|
const normalizedProductIds = productRequirementEntries.value
|
||||||
typeof editionForm.productId === 'string'
|
.map((entry) => productSelections.value[entry.index])
|
||||||
? editionForm.productId.trim()
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
: ''
|
.map((value) => value.trim())
|
||||||
payload.productId = selectedProductId || null
|
|
||||||
|
payload.productIds = normalizedProductIds
|
||||||
|
payload.productId = normalizedProductIds[0] || null
|
||||||
|
|
||||||
if (rawPrice) {
|
if (rawPrice) {
|
||||||
const parsed = Number(rawPrice)
|
const parsed = Number(rawPrice)
|
||||||
@@ -981,12 +1219,6 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
|
|||||||
return String(defaultValue)
|
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) => ({
|
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||||
customFieldName: field.name,
|
customFieldName: field.name,
|
||||||
customFieldType: field.type,
|
customFieldType: field.type,
|
||||||
|
|||||||
@@ -118,12 +118,26 @@
|
|||||||
<span>{{ description }}</span>
|
<span>{{ description }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ProductSelect
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
v-model="creationForm.productId"
|
<div
|
||||||
:disabled="submitting || !selectedType"
|
v-for="entry in productRequirementEntries"
|
||||||
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
:key="entry.key"
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
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>
|
||||||
|
|
||||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
<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,
|
reference: '' as string,
|
||||||
constructeurIds: [] as string[],
|
constructeurIds: [] as string[],
|
||||||
prix: '' as string,
|
prix: '' as string,
|
||||||
productId: null as string | null,
|
|
||||||
})
|
})
|
||||||
|
const productSelections = ref<(string | null)[]>([])
|
||||||
|
|
||||||
const lastSuggestedName = ref('')
|
const lastSuggestedName = ref('')
|
||||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||||
@@ -364,14 +378,18 @@ const selectedType = computed(() => {
|
|||||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
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(() =>
|
const structureProducts = computed(() =>
|
||||||
getStructureProducts(selectedType.value?.structure ?? null),
|
getStructureProducts(selectedType.value?.structure ?? null),
|
||||||
)
|
)
|
||||||
|
|
||||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||||
|
|
||||||
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
|
||||||
|
|
||||||
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||||
if (!requirement) {
|
if (!requirement) {
|
||||||
return `Produit ${index + 1}`
|
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) => {
|
watch(selectedType, (type) => {
|
||||||
if (!type) {
|
if (!type) {
|
||||||
clearCreationForm()
|
clearCreationForm()
|
||||||
@@ -411,7 +462,7 @@ watch(selectedType, (type) => {
|
|||||||
}
|
}
|
||||||
lastSuggestedName.value = creationForm.name
|
lastSuggestedName.value = creationForm.name
|
||||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||||
creationForm.productId = null
|
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
||||||
})
|
})
|
||||||
|
|
||||||
const requiredCustomFieldsFilled = computed(() =>
|
const requiredCustomFieldsFilled = computed(() =>
|
||||||
@@ -431,7 +482,7 @@ const canSubmit = computed(() =>
|
|||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
(!requiresProductSelection.value || creationForm.productId) &&
|
productSelectionsFilled.value &&
|
||||||
!submitting.value,
|
!submitting.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -449,18 +500,12 @@ const toFieldString = (value: unknown): string => {
|
|||||||
return ''
|
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 = () => {
|
const clearCreationForm = () => {
|
||||||
creationForm.name = ''
|
creationForm.name = ''
|
||||||
creationForm.reference = ''
|
creationForm.reference = ''
|
||||||
creationForm.constructeurIds = []
|
creationForm.constructeurIds = []
|
||||||
creationForm.prix = ''
|
creationForm.prix = ''
|
||||||
creationForm.productId = null
|
productSelections.value = []
|
||||||
lastSuggestedName.value = ''
|
lastSuggestedName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +515,7 @@ const submitCreation = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresProductSelection.value && !creationForm.productId) {
|
if (!productSelectionsFilled.value) {
|
||||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -487,12 +532,13 @@ const submitCreation = async () => {
|
|||||||
|
|
||||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||||
|
|
||||||
const selectedProductId =
|
const normalizedProductIds = productRequirementEntries.value
|
||||||
typeof creationForm.productId === 'string'
|
.map((entry) => productSelections.value[entry.index])
|
||||||
? creationForm.productId.trim()
|
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||||
: ''
|
.map((value) => value.trim())
|
||||||
if (selectedProductId) {
|
if (normalizedProductIds.length) {
|
||||||
payload.productId = selectedProductId
|
payload.productIds = normalizedProductIds
|
||||||
|
payload.productId = normalizedProductIds[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawPrice = typeof creationForm.prix === 'string'
|
const rawPrice = typeof creationForm.prix === 'string'
|
||||||
|
|||||||
@@ -26,6 +26,8 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +40,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +51,21 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
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(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
|
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 ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -99,6 +119,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -301,6 +301,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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 }">
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -329,6 +397,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
@@ -359,6 +428,12 @@ const {
|
|||||||
deleteDocument: deleteProductDocument,
|
deleteDocument: deleteProductDocument,
|
||||||
} = useDocuments()
|
} = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = useProductHistory()
|
||||||
|
|
||||||
const product = ref<any | null>(null)
|
const product = ref<any | null>(null)
|
||||||
const productType = 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 previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
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 = (
|
const refreshCustomFieldInputs = (
|
||||||
structureOverride?: ProductModelStructure | null,
|
structureOverride?: ProductModelStructure | null,
|
||||||
valuesOverride?: any[] | null,
|
valuesOverride?: any[] | null,
|
||||||
@@ -509,6 +664,7 @@ const loadProduct = async () => {
|
|||||||
}
|
}
|
||||||
await hydrateForm()
|
await hydrateForm()
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
product.value = null
|
product.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
const query: Record<string, string | number> = {};
|
const query: Record<string, string | number> = {};
|
||||||
|
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
query.q = params.q;
|
query.name = params.q;
|
||||||
}
|
}
|
||||||
if (params.category) {
|
if (params.category) {
|
||||||
query.category = params.category;
|
query.category = params.category;
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
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({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
|
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',
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
|
||||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||||
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
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',
|
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
|
||||||
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
||||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
||||||
|
|||||||
Reference in New Issue
Block a user