Compare commits
15 Commits
v1.3.0
...
2fffe4a368
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fffe4a368 | ||
|
|
c9054e5b4d | ||
|
|
5cab15422d | ||
|
|
439db8117a | ||
|
|
675820532c | ||
|
|
4edfc55c37 | ||
|
|
480aaa24b2 | ||
|
|
185af65519 | ||
|
|
8fecf67a7f | ||
|
|
79d2df8bc6 | ||
|
|
23da4ba4c7 | ||
|
|
635b8f0461 | ||
|
|
bf74a50f57 | ||
|
|
7c44778f25 | ||
|
|
9f7dd12b34 |
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||||
<div class="items-center grid-flow-col">
|
<div class="items-center grid-flow-col">
|
||||||
<p>@Malio 2025 · v{{ appVersion }}</p>
|
<p>
|
||||||
|
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,26 +6,31 @@
|
|||||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||||
color-scheme: light; /* color of browser-provided UI */
|
color-scheme: light; /* color of browser-provided UI */
|
||||||
|
|
||||||
--color-base-100: oklch(98% 0.02 240);
|
/* #FBFAFA — gris clair */
|
||||||
--color-base-200: oklch(95% 0.03 240);
|
--color-base-100: oklch(98% 0.003 0);
|
||||||
--color-base-300: oklch(92% 0.04 240);
|
--color-base-200: oklch(94% 0.01 262);
|
||||||
--color-base-content: oklch(20% 0.05 240);
|
--color-base-300: oklch(90% 0.02 262);
|
||||||
--color-primary: oklch(55% 0.3 240);
|
--color-base-content: oklch(20% 0.03 262);
|
||||||
--color-primary-content: oklch(98% 0.01 240);
|
/* #304998 — bleu Malio */
|
||||||
--color-secondary: oklch(70% 0.25 200);
|
--color-primary: oklch(37% 0.15 262);
|
||||||
--color-secondary-content: oklch(98% 0.01 200);
|
--color-primary-content: oklch(98% 0.005 262);
|
||||||
--color-accent: oklch(65% 0.25 160);
|
/* #A5ACD0 — lavande */
|
||||||
--color-accent-content: oklch(98% 0.01 160);
|
--color-secondary: oklch(75% 0.055 270);
|
||||||
--color-neutral: oklch(50% 0.05 240);
|
--color-secondary-content: oklch(20% 0.03 270);
|
||||||
--color-neutral-content: oklch(98% 0.01 240);
|
/* #ED8521 — orange */
|
||||||
--color-info: oklch(70% 0.2 220);
|
--color-accent: oklch(71% 0.17 58);
|
||||||
--color-info-content: oklch(98% 0.01 220);
|
--color-accent-content: oklch(98% 0.005 58);
|
||||||
--color-success: oklch(65% 0.25 140);
|
/* neutral dérivé du bleu Malio */
|
||||||
--color-success-content: oklch(98% 0.01 140);
|
--color-neutral: oklch(37% 0.08 262);
|
||||||
--color-warning: oklch(80% 0.25 80);
|
--color-neutral-content: oklch(98% 0.005 262);
|
||||||
--color-warning-content: oklch(20% 0.05 80);
|
--color-info: oklch(55% 0.12 262);
|
||||||
--color-error: oklch(65% 0.3 30);
|
--color-info-content: oklch(98% 0.005 262);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--color-success: oklch(65% 0.2 145);
|
||||||
|
--color-success-content: oklch(98% 0.005 145);
|
||||||
|
--color-warning: oklch(78% 0.15 70);
|
||||||
|
--color-warning-content: oklch(20% 0.05 70);
|
||||||
|
--color-error: oklch(60% 0.25 25);
|
||||||
|
--color-error-content: oklch(98% 0.005 25);
|
||||||
|
|
||||||
/* border radius */
|
/* border radius */
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
|
|
||||||
/* Focus visible pour l'accessibilité */
|
/* Focus visible pour l'accessibilité */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #304998;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -275,11 +275,12 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'resources',
|
id: 'resources',
|
||||||
label: 'Ressources liées',
|
label: 'Ressources liées',
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs'],
|
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
{ to: '/documents', label: 'Documents' },
|
{ to: '/documents', label: 'Documents' },
|
||||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||||
|
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
172
app/components/model-types/ConversionModal.vue
Normal file
172
app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
|
Convertir la catégorie
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Vérification de la conversion…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||||
|
{{ checkError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocked state -->
|
||||||
|
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||||
|
<p class="mt-3 text-sm text-base-content/70">
|
||||||
|
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(blocker, i) in checkResult.blockers"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||||
|
>
|
||||||
|
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
{{ blocker }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Eligible state -->
|
||||||
|
<template v-else-if="checkResult && checkResult.canConvert">
|
||||||
|
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||||
|
<p class="text-sm font-medium text-warning">
|
||||||
|
{{ directionLabel }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="checkResult.names.length > 0"
|
||||||
|
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||||
|
>
|
||||||
|
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||||
|
Éléments concernés :
|
||||||
|
</p>
|
||||||
|
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in checkResult.names"
|
||||||
|
:key="i"
|
||||||
|
class="py-1.5 text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||||
|
{{ convertError }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="checkResult?.canConvert"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="doConvert"
|
||||||
|
>
|
||||||
|
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||||
|
import {
|
||||||
|
checkConversion,
|
||||||
|
convertCategory,
|
||||||
|
type ConversionCheck,
|
||||||
|
type ModelType,
|
||||||
|
} from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
modelType: ModelType | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'converted'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const checking = ref(false);
|
||||||
|
const checkError = ref<string | null>(null);
|
||||||
|
const checkResult = ref<ConversionCheck | null>(null);
|
||||||
|
const converting = ref(false);
|
||||||
|
const convertError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const directionLabel = computed(() => {
|
||||||
|
if (!checkResult.value) return '';
|
||||||
|
return checkResult.value.direction === 'piece_to_component'
|
||||||
|
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||||
|
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (!isOpen || !props.modelType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checking.value = true;
|
||||||
|
checkError.value = null;
|
||||||
|
checkResult.value = null;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
checkResult.value = await checkConversion(props.modelType.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
checkError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const doConvert = async () => {
|
||||||
|
if (!props.modelType) return;
|
||||||
|
|
||||||
|
converting.value = true;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await convertCategory(props.modelType.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
convertError.value = result.error || 'La conversion a échoué.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('converted');
|
||||||
|
} catch (err: any) {
|
||||||
|
convertError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||||
|
} finally {
|
||||||
|
converting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -29,12 +29,21 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
|
:category="selectedCategory"
|
||||||
@related="openRelatedModal"
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
|
@convert="openConversionModal"
|
||||||
@update:offset="onOffsetChange"
|
@update:offset="onOffsetChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelTypesConversionModal
|
||||||
|
:open="conversionModalOpen"
|
||||||
|
:model-type="conversionTarget"
|
||||||
|
@close="closeConversionModal"
|
||||||
|
@converted="onConverted"
|
||||||
|
/>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||||
<div class="modal-box max-w-3xl">
|
<div class="modal-box max-w-3xl">
|
||||||
<h3 class="text-lg font-bold text-base-content">
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
@@ -92,11 +101,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } 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 ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||||
import { useApi } from "~/composables/useApi";
|
import { useApi } from "~/composables/useApi";
|
||||||
|
import { useUrlState } from "~/composables/useUrlState";
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
@@ -125,11 +136,28 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category);
|
||||||
const searchInput = ref("");
|
const searchInput = ref("");
|
||||||
const searchTerm = ref("");
|
|
||||||
const sort = ref<"name" | "createdAt">("name");
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const dir = ref<"asc" | "desc">("asc");
|
const urlState = useUrlState({
|
||||||
const limit = ref(20);
|
q: { default: '' },
|
||||||
const offset = ref(0);
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
limit: { default: 20, type: 'number' },
|
||||||
|
offset: { default: 0, type: 'number' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => {
|
||||||
|
searchInput.value = urlState.q.value;
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const searchTerm = urlState.q;
|
||||||
|
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
||||||
|
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
||||||
|
const limit = urlState.limit;
|
||||||
|
const offset = urlState.offset;
|
||||||
|
|
||||||
|
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
||||||
|
searchInput.value = searchTerm.value;
|
||||||
|
|
||||||
const items = ref<ModelType[]>([]);
|
const items = ref<ModelType[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
@@ -466,6 +494,26 @@ const closeRelatedModal = () => {
|
|||||||
relatedModalOpen.value = false;
|
relatedModalOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const conversionModalOpen = ref(false);
|
||||||
|
const conversionTarget = ref<ModelType | null>(null);
|
||||||
|
|
||||||
|
const openConversionModal = (item: ModelType) => {
|
||||||
|
conversionTarget.value = item;
|
||||||
|
conversionModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeConversionModal = () => {
|
||||||
|
conversionModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConverted = () => {
|
||||||
|
conversionModalOpen.value = false;
|
||||||
|
invalidateEntityTypeCache("PIECE");
|
||||||
|
invalidateEntityTypeCache("COMPONENT");
|
||||||
|
showSuccess("Catégorie convertie avec succès.");
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -48,6 +48,15 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</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>
|
||||||
@@ -78,6 +87,15 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</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>
|
||||||
@@ -118,6 +136,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import IconLucideInbox from '~icons/lucide/inbox';
|
import IconLucideInbox from '~icons/lucide/inbox';
|
||||||
|
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -126,15 +145,21 @@ const props = defineProps<{
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
category?: ModelCategory;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'related', item: ModelType): void;
|
(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: 'convert', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): void;
|
(e: 'update:offset', offset: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||||
|
);
|
||||||
|
|
||||||
const categoryDictionary: Record<ModelCategory, string> = {
|
const categoryDictionary: Record<ModelCategory, string> = {
|
||||||
COMPONENT: 'Composants',
|
COMPONENT: 'Composants',
|
||||||
PIECE: 'Pièces',
|
PIECE: 'Pièces',
|
||||||
|
|||||||
70
app/composables/useActivityLog.ts
Normal file
70
app/composables/useActivityLog.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
|
||||||
|
export type ActivityLogActor = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActivityLogEntry = {
|
||||||
|
id: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName: string | null
|
||||||
|
entityRef: string | null
|
||||||
|
action: 'create' | 'update' | 'delete' | string
|
||||||
|
createdAt: string
|
||||||
|
actor: ActivityLogActor | null
|
||||||
|
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||||
|
snapshot: Record<string, unknown> | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadActivityLogOptions {
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
entityType?: string
|
||||||
|
action?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useActivityLog() {
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
|
const entries = ref<ActivityLogEntry[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const loadActivityLog = async (options: LoadActivityLogOptions = {}) => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('page', String(options.page ?? 1))
|
||||||
|
params.set('itemsPerPage', String(options.itemsPerPage ?? 30))
|
||||||
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
|
if (options.action) params.set('action', options.action)
|
||||||
|
|
||||||
|
const result = await get(`/activity-logs?${params.toString()}`)
|
||||||
|
if (!result.success) {
|
||||||
|
error.value = result.error ?? 'Impossible de charger le journal d\'activité.'
|
||||||
|
entries.value = []
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.data as any
|
||||||
|
entries.value = Array.isArray(data?.items) ? data.items : []
|
||||||
|
total.value = typeof data?.total === 'number' ? data.total : entries.value.length
|
||||||
|
|
||||||
|
return { success: true, data: entries.value }
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err?.message ?? 'Erreur inconnue'
|
||||||
|
error.value = message
|
||||||
|
entries.value = []
|
||||||
|
return { success: false, error: message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, total, loading, error, loadActivityLog }
|
||||||
|
}
|
||||||
@@ -40,11 +40,13 @@ interface LoadComposantsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const composants = ref<Composant[]>([])
|
const composants = ref<Composant[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
@@ -98,15 +100,31 @@ export function useComposants() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
search = '',
|
|
||||||
page = 1,
|
|
||||||
itemsPerPage = 30,
|
|
||||||
orderBy = 'name',
|
|
||||||
orderDir = 'asc',
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
@@ -124,6 +142,7 @@ export function useComposants() {
|
|||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
composants.value = enrichedItems
|
composants.value = enrichedItems
|
||||||
total.value = extractTotal(result.data, items.length)
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -216,15 +235,23 @@ export function useComposants() {
|
|||||||
const getComposants = () => composants.value
|
const getComposants = () => composants.value
|
||||||
const isLoading = () => loading.value
|
const isLoading = () => loading.value
|
||||||
|
|
||||||
|
const clearComposantsCache = () => {
|
||||||
|
composants.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
composants,
|
composants,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadComposants,
|
loadComposants,
|
||||||
createComposant,
|
createComposant,
|
||||||
updateComposant: updateComposantData,
|
updateComposant: updateComposantData,
|
||||||
deleteComposant,
|
deleteComposant,
|
||||||
getComposants,
|
getComposants,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
clearComposantsCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ export function useDocuments() {
|
|||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get(endpoint)
|
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||||
|
const result = await get(url)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = extractCollection(result.data)
|
const data = extractCollection(result.data)
|
||||||
if (updateStore) {
|
if (updateStore) {
|
||||||
@@ -76,9 +77,9 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (
|
||||||
options: { updateStore?: boolean } = {},
|
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocumentsBySite = async (
|
const loadDocumentsBySite = async (
|
||||||
|
|||||||
@@ -41,11 +41,13 @@ interface LoadPiecesOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieces = ref<Piece[]>([])
|
const pieces = ref<Piece[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
@@ -108,15 +110,31 @@ export function usePieces() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
search = '',
|
|
||||||
page = 1,
|
|
||||||
itemsPerPage = 30,
|
|
||||||
orderBy = 'name',
|
|
||||||
orderDir = 'asc',
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
@@ -134,6 +152,7 @@ export function usePieces() {
|
|||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
pieces.value = enrichedItems
|
pieces.value = enrichedItems
|
||||||
total.value = extractTotal(result.data, items.length)
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -226,15 +245,23 @@ export function usePieces() {
|
|||||||
const getPieces = () => pieces.value
|
const getPieces = () => pieces.value
|
||||||
const isLoading = () => loading.value
|
const isLoading = () => loading.value
|
||||||
|
|
||||||
|
const clearPiecesCache = () => {
|
||||||
|
pieces.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pieces,
|
pieces,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadPieces,
|
loadPieces,
|
||||||
createPiece,
|
createPiece,
|
||||||
updatePiece: updatePieceData,
|
updatePiece: updatePieceData,
|
||||||
deletePiece,
|
deletePiece,
|
||||||
getPieces,
|
getPieces,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
clearPiecesCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
app/composables/useUrlState.ts
Normal file
116
app/composables/useUrlState.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { ref, watch, nextTick, type Ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from '#imports'
|
||||||
|
|
||||||
|
interface ParamDef<T extends string | number = string | number> {
|
||||||
|
default: T
|
||||||
|
type?: 'string' | 'number'
|
||||||
|
/** Debounce URL writes (ms). Default: 0 (immediate). */
|
||||||
|
debounce?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParamDefs = Record<string, ParamDef>
|
||||||
|
|
||||||
|
type InferRef<D extends ParamDef> = D['default'] extends number ? Ref<number> : Ref<string>
|
||||||
|
|
||||||
|
type StateRefs<T extends ParamDefs> = {
|
||||||
|
[K in keyof T]: InferRef<T[K]>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseUrlStateOptions {
|
||||||
|
/** Called when state is restored from URL (back/forward navigation). */
|
||||||
|
onRestore?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUrlState<T extends ParamDefs>(
|
||||||
|
params: T,
|
||||||
|
options?: UseUrlStateOptions,
|
||||||
|
): StateRefs<T> {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const keys = Object.keys(params) as (keyof T & string)[]
|
||||||
|
const refs: Record<string, Ref<string | number>> = {}
|
||||||
|
const timers: Record<string, ReturnType<typeof setTimeout> | null> = {}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
refs[key] = ref(parseValue(route.query[key], params[key]!))
|
||||||
|
timers[key] = null
|
||||||
|
}
|
||||||
|
|
||||||
|
let isProgrammatic = false
|
||||||
|
|
||||||
|
const buildQuery = (): Record<string, string> => {
|
||||||
|
const q: Record<string, string> = {}
|
||||||
|
for (const key of keys) {
|
||||||
|
const val = refs[key]!.value
|
||||||
|
if (val !== params[key]!.default) {
|
||||||
|
q[key] = String(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushToUrl = () => {
|
||||||
|
if (isProgrammatic) return
|
||||||
|
isProgrammatic = true
|
||||||
|
const query = buildQuery()
|
||||||
|
router
|
||||||
|
.replace({ path: route.path, query })
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
isProgrammatic = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const ms = params[key]!.debounce ?? 0
|
||||||
|
watch(refs[key]!, () => {
|
||||||
|
if (isProgrammatic) return
|
||||||
|
if (ms > 0) {
|
||||||
|
if (timers[key]) clearTimeout(timers[key]!)
|
||||||
|
timers[key] = setTimeout(pushToUrl, ms)
|
||||||
|
} else {
|
||||||
|
pushToUrl()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => ({ ...route.query }),
|
||||||
|
(newQuery) => {
|
||||||
|
if (isProgrammatic) return
|
||||||
|
isProgrammatic = true
|
||||||
|
let changed = false
|
||||||
|
for (const key of keys) {
|
||||||
|
const parsed = parseValue(newQuery[key], params[key]!)
|
||||||
|
if (refs[key]!.value !== parsed) {
|
||||||
|
refs[key]!.value = parsed
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
isProgrammatic = false
|
||||||
|
if (changed && options?.onRestore) {
|
||||||
|
options.onRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return refs as StateRefs<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseValue(
|
||||||
|
raw: unknown,
|
||||||
|
def: ParamDef,
|
||||||
|
): string | number {
|
||||||
|
const str = typeof raw === 'string' ? raw : null
|
||||||
|
if (str === null) return def.default
|
||||||
|
if (def.type === 'number' || typeof def.default === 'number') {
|
||||||
|
const n = Number(str)
|
||||||
|
return Number.isFinite(n) ? n : def.default
|
||||||
|
}
|
||||||
|
return str
|
||||||
|
}
|
||||||
274
app/pages/activity-log.vue
Normal file
274
app/pages/activity-log.vue
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Historique des modifications sur l'ensemble des pièces, produits et composants.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="activity-entity-type"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="activity-entity-type"
|
||||||
|
v-model="entityTypeFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="">Tous</option>
|
||||||
|
<option value="piece">Pièce</option>
|
||||||
|
<option value="product">Produit</option>
|
||||||
|
<option value="composant">Composant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="activity-action"
|
||||||
|
>
|
||||||
|
Action
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="activity-action"
|
||||||
|
v-model="actionFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="">Toutes</option>
|
||||||
|
<option value="create">Création</option>
|
||||||
|
<option value="update">Modification</option>
|
||||||
|
<option value="delete">Suppression</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="activity-per-page"
|
||||||
|
>
|
||||||
|
Par page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="activity-per-page"
|
||||||
|
v-model.number="itemsPerPage"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/50 lg:text-right">
|
||||||
|
{{ entries.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-8">
|
||||||
|
<span class="loading loading-spinner" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="!total" class="text-sm text-base-content/70">
|
||||||
|
Aucune activité enregistrée.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-else-if="!entries.length" class="text-sm text-base-content/70">
|
||||||
|
Aucune activité ne correspond à vos filtres.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm md:table-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Entité</th>
|
||||||
|
<th>Auteur</th>
|
||||||
|
<th>Détails</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="entry in entries" :key="entry.id">
|
||||||
|
<tr>
|
||||||
|
<td class="whitespace-nowrap">{{ formatHistoryDate(entry.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="actionBadgeClass(entry.action)"
|
||||||
|
>
|
||||||
|
{{ historyActionLabel(entry.action) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-ghost badge-sm">
|
||||||
|
{{ entityTypeLabel(entry.entityType) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="entry.action !== 'delete'"
|
||||||
|
:to="entityEditLink(entry)"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ entry.entityName || 'Sans nom' }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-base-content/50 line-through">
|
||||||
|
{{ entry.entityName || 'Sans nom' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="entry.entityRef"
|
||||||
|
class="text-xs text-base-content/50 ml-1"
|
||||||
|
>
|
||||||
|
({{ entry.entityRef }})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ entry.actor?.label || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="hasDiff(entry)"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="toggleExpanded(entry.id)"
|
||||||
|
>
|
||||||
|
{{ expandedIds.has(entry.id) ? 'Masquer' : 'Voir' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-base-content/50">—</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="expandedIds.has(entry.id)">
|
||||||
|
<td colspan="6" class="bg-base-200/50 p-4">
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div
|
||||||
|
v-for="diffEntry in historyDiffEntries(entry, globalFieldLabels)"
|
||||||
|
:key="diffEntry.field"
|
||||||
|
class="flex gap-2"
|
||||||
|
>
|
||||||
|
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
|
||||||
|
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span class="text-success">{{ diffEntry.toLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
:current-page="currentPage"
|
||||||
|
:total-pages="totalPages"
|
||||||
|
@update:current-page="handlePageChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useActivityLog } from '~/composables/useActivityLog'
|
||||||
|
import type { ActivityLogEntry } from '~/composables/useActivityLog'
|
||||||
|
import {
|
||||||
|
historyActionLabel,
|
||||||
|
formatHistoryDate,
|
||||||
|
historyDiffEntries,
|
||||||
|
} from '~/shared/utils/historyDisplayUtils'
|
||||||
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
|
|
||||||
|
const { entries, total, loading, loadActivityLog } = useActivityLog()
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const itemsPerPage = ref(50)
|
||||||
|
const totalPages = computed(() => Math.ceil(total.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
|
const entityTypeFilter = ref('')
|
||||||
|
const actionFilter = ref('')
|
||||||
|
|
||||||
|
const expandedIds = reactive(new Set<string>())
|
||||||
|
|
||||||
|
const toggleExpanded = (id: string) => {
|
||||||
|
if (expandedIds.has(id)) expandedIds.delete(id)
|
||||||
|
else expandedIds.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDiff = (entry: ActivityLogEntry) =>
|
||||||
|
entry.diff !== null && entry.diff !== undefined && Object.keys(entry.diff).length > 0
|
||||||
|
|
||||||
|
const fetchLog = () => {
|
||||||
|
loadActivityLog({
|
||||||
|
page: currentPage.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
entityType: entityTypeFilter.value || undefined,
|
||||||
|
action: actionFilter.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
currentPage.value = page
|
||||||
|
fetchLog()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
piece: 'Pièce',
|
||||||
|
product: 'Produit',
|
||||||
|
composant: 'Composant',
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
|
const ENTITY_EDIT_ROUTES: Record<string, string> = {
|
||||||
|
piece: '/pieces',
|
||||||
|
product: '/product',
|
||||||
|
composant: '/component',
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityEditLink = (entry: ActivityLogEntry) => {
|
||||||
|
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
|
||||||
|
return base ? `${base}/${entry.entityId}/edit` : '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionBadgeClass = (action: string) => {
|
||||||
|
if (action === 'create') return 'badge-success'
|
||||||
|
if (action === 'delete') return 'badge-error'
|
||||||
|
return 'badge-warning'
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalFieldLabels: Record<string, string> = {
|
||||||
|
name: 'Nom',
|
||||||
|
reference: 'Référence',
|
||||||
|
prix: 'Prix',
|
||||||
|
supplierPrice: 'Prix fournisseur',
|
||||||
|
typePiece: 'Type de pièce',
|
||||||
|
typeProduct: 'Type de produit',
|
||||||
|
typeComposant: 'Type de composant',
|
||||||
|
product: 'Produit',
|
||||||
|
productIds: 'Produits',
|
||||||
|
constructeurIds: 'Fournisseurs',
|
||||||
|
structure: 'Structure',
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchLog)
|
||||||
|
</script>
|
||||||
173
app/pages/changelog.vue
Normal file
173
app/pages/changelog.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
|
||||||
|
<header class="space-y-2">
|
||||||
|
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Historique des modifications et nouvelles fonctionnalités de l'application.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
v-for="release in releases"
|
||||||
|
:key="release.version"
|
||||||
|
class="card border border-base-200 bg-base-100 shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="card-body space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-bold text-base-content">
|
||||||
|
{{ release.version }}
|
||||||
|
</h2>
|
||||||
|
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in release.changes"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 text-sm text-base-content/80"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm mt-0.5 shrink-0"
|
||||||
|
:class="badgeClass(item.type)"
|
||||||
|
>
|
||||||
|
{{ item.type }}
|
||||||
|
</span>
|
||||||
|
<span>{{ item.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useHead } from '#imports'
|
||||||
|
|
||||||
|
useHead({ title: 'Changelog' })
|
||||||
|
|
||||||
|
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
|
||||||
|
|
||||||
|
interface Change {
|
||||||
|
type: ChangeType
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Release {
|
||||||
|
version: string
|
||||||
|
date: string
|
||||||
|
changes: Change[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const badgeClass = (type: ChangeType) => {
|
||||||
|
const map: Record<ChangeType, string> = {
|
||||||
|
feat: 'badge-primary',
|
||||||
|
fix: 'badge-error',
|
||||||
|
perf: 'badge-warning',
|
||||||
|
chore: 'badge-ghost',
|
||||||
|
}
|
||||||
|
return map[type] ?? 'badge-ghost'
|
||||||
|
}
|
||||||
|
|
||||||
|
const releases: Release[] = [
|
||||||
|
{
|
||||||
|
version: 'v1.6.0',
|
||||||
|
date: '2026-02-12',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
|
||||||
|
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
|
||||||
|
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
|
||||||
|
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.5.0',
|
||||||
|
date: '2026-02-11',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
|
||||||
|
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
|
||||||
|
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
|
||||||
|
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
|
||||||
|
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
|
||||||
|
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
|
||||||
|
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
|
||||||
|
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
|
||||||
|
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
|
||||||
|
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
|
||||||
|
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
|
||||||
|
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
|
||||||
|
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
|
||||||
|
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.4.0',
|
||||||
|
date: '2026-02-04',
|
||||||
|
changes: [
|
||||||
|
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
|
||||||
|
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.3.0',
|
||||||
|
date: '2026-01-28',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
|
||||||
|
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
|
||||||
|
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
|
||||||
|
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
|
||||||
|
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
|
||||||
|
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
|
||||||
|
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
|
||||||
|
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
|
||||||
|
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
|
||||||
|
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
|
||||||
|
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.2.0',
|
||||||
|
date: '2026-01-21',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
|
||||||
|
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
|
||||||
|
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
|
||||||
|
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.1.1',
|
||||||
|
date: '2026-01-14',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
|
||||||
|
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.1.0',
|
||||||
|
date: '2026-01-07',
|
||||||
|
changes: [
|
||||||
|
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
|
||||||
|
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
|
||||||
|
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 'v1.0.0',
|
||||||
|
date: '2025-12-15',
|
||||||
|
changes: [
|
||||||
|
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
|
||||||
|
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
|
||||||
|
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
|
||||||
|
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
|
||||||
|
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
|
||||||
|
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
|
||||||
|
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
|
||||||
|
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
|
||||||
|
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
|
||||||
|
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
|
||||||
|
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
|
||||||
|
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
</script>
|
||||||
@@ -130,7 +130,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||||
<td>{{ component.reference || '—' }}</td>
|
<td>{{ component.reference || '—' }}</td>
|
||||||
<td>{{ resolveComponentType(component) }}</td>
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="component.typeComposant?.id"
|
||||||
|
:to="`/component-category/${component.typeComposant.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolveComponentType(component) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolveComponentType(component) }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -167,11 +176,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
@@ -181,15 +190,28 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
|
|||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||||
|
|
||||||
// Pagination state
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const currentPage = ref(1)
|
const {
|
||||||
const itemsPerPage = ref(30)
|
page: currentPage,
|
||||||
|
perPage: itemsPerPage,
|
||||||
|
q: searchTerm,
|
||||||
|
sort: sortField,
|
||||||
|
dir: sortDirection,
|
||||||
|
} = useUrlState({
|
||||||
|
page: { default: 1, type: 'number' },
|
||||||
|
perPage: { default: 20, type: 'number' },
|
||||||
|
q: { default: '', debounce: 300 },
|
||||||
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => fetchComposants(),
|
||||||
|
})
|
||||||
|
|
||||||
const composantsTotal = computed(() => total.value)
|
const composantsTotal = computed(() => total.value)
|
||||||
const composantsOnPage = computed(() => composants.value.length)
|
const composantsOnPage = computed(() => composants.value.length)
|
||||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
// Search state with debounce
|
// Search debounce for API calls
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
@@ -202,12 +224,6 @@ const debouncedSearch = () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort state
|
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
|
||||||
'component-catalog',
|
|
||||||
{ field: 'name', direction: 'asc' },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enrichir les composants avec les types de composants complets
|
// Enrichir les composants avec les types de composants complets
|
||||||
const composantsList = computed(() => {
|
const composantsList = computed(() => {
|
||||||
return (composants.value || []).map((composant) => {
|
return (composants.value || []).map((composant) => {
|
||||||
@@ -225,7 +241,8 @@ const fetchComposants = async () => {
|
|||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: sortField.value,
|
||||||
orderDir: sortDirection.value
|
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||||
|
force: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
Mettez à jour les informations du composant et ses champs personnalisés.
|
Mettez à jour les informations du composant et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -576,7 +576,7 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
|
|||||||
const { pieces, loadPieces } = usePieces()
|
const { pieces, loadPieces } = usePieces()
|
||||||
const { products, loadProducts } = useProducts()
|
const { products, loadProducts } = useProducts()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const {
|
const {
|
||||||
@@ -764,12 +764,10 @@ const fetchComponent = async () => {
|
|||||||
component.value = result.data
|
component.value = result.data
|
||||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
|
||||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
component.value.customFieldValues = customValues.data
|
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
loadHistory(result.data.id).catch(() => {})
|
||||||
}
|
|
||||||
await loadHistory(result.data.id)
|
|
||||||
} else {
|
} else {
|
||||||
component.value = null
|
component.value = null
|
||||||
componentDocuments.value = []
|
componentDocuments.value = []
|
||||||
@@ -805,7 +803,9 @@ watch(
|
|||||||
void ensureConstructeurs(editionForm.constructeurIds)
|
void ensureConstructeurs(editionForm.constructeurIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
|
// After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
|
||||||
|
// the stale destructured currentStructure which was captured before the ID change.
|
||||||
|
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
@@ -1130,14 +1130,15 @@ onMounted(async () => {
|
|||||||
loadComponentTypes(),
|
loadComponentTypes(),
|
||||||
loadPieceTypes(),
|
loadPieceTypes(),
|
||||||
loadProductTypes(),
|
loadProductTypes(),
|
||||||
loadPieces({ itemsPerPage: 500 }),
|
|
||||||
loadProducts({ itemsPerPage: 500, force: true }),
|
|
||||||
loadComposants({ itemsPerPage: 500 }),
|
|
||||||
fetchComponent(),
|
fetchComponent(),
|
||||||
])
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (component.value?.id) {
|
|
||||||
await refreshDocuments()
|
// Defer bulk catalog loads — not needed for initial render
|
||||||
}
|
Promise.allSettled([
|
||||||
|
loadPieces({ itemsPerPage: 200 }),
|
||||||
|
loadProducts({ itemsPerPage: 200 }),
|
||||||
|
loadComposants({ itemsPerPage: 200 }),
|
||||||
|
]).catch(() => {})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
@@ -139,14 +141,17 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|||||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||||
|
|
||||||
const { documents, loading, loadDocuments } = useDocuments()
|
const { documents, loading, loadDocuments } = useDocuments()
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||||
const attachmentFilter = ref('all')
|
q: { default: '', debounce: 300 },
|
||||||
|
filter: { default: 'all' },
|
||||||
|
})
|
||||||
const previewDocument = ref(null)
|
const previewDocument = ref(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDocuments()
|
loadDocuments({ itemsPerPage: 200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredDocuments = computed(() => {
|
const filteredDocuments = computed(() => {
|
||||||
@@ -156,10 +161,10 @@ const filteredDocuments = computed(() => {
|
|||||||
return documents.value.filter((document) => {
|
return documents.value.filter((document) => {
|
||||||
const matchesFilter =
|
const matchesFilter =
|
||||||
filter === 'all' ||
|
filter === 'all' ||
|
||||||
(filter === 'site' && document.siteId) ||
|
(filter === 'site' && document.site) ||
|
||||||
(filter === 'machine' && document.machineId) ||
|
(filter === 'machine' && document.machine) ||
|
||||||
(filter === 'composant' && document.composantId) ||
|
(filter === 'composant' && document.composant) ||
|
||||||
(filter === 'piece' && document.pieceId)
|
(filter === 'piece' && document.piece)
|
||||||
|
|
||||||
if (!matchesFilter) { return false }
|
if (!matchesFilter) { return false }
|
||||||
|
|
||||||
@@ -192,22 +197,36 @@ const formatSize = (size) => {
|
|||||||
|
|
||||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||||
|
|
||||||
const downloadDocument = (doc) => {
|
/** Fetch the full document (with path) from the API on demand. */
|
||||||
if (!doc?.path) { return }
|
const fetchDocumentPath = async (doc) => {
|
||||||
|
if (doc?.path) { return doc.path }
|
||||||
|
if (!doc?.id) { return null }
|
||||||
|
const result = await get(`/documents/${doc.id}`)
|
||||||
|
if (result.success && result.data?.path) {
|
||||||
|
doc.path = result.data.path
|
||||||
|
return result.data.path
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.path.startsWith('data:')) {
|
const downloadDocument = async (doc) => {
|
||||||
|
const path = await fetchDocumentPath(doc)
|
||||||
|
if (!path) { return }
|
||||||
|
|
||||||
|
if (path.startsWith('data:')) {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = doc.path
|
link.href = path
|
||||||
link.download = doc.filename || doc.name || 'document'
|
link.download = doc.filename || doc.name || 'document'
|
||||||
link.click()
|
link.click()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(doc.path, '_blank')
|
window.open(path, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPreview = (doc) => {
|
const openPreview = async (doc) => {
|
||||||
if (!canPreviewDocument(doc)) { return }
|
if (!canPreviewDocument(doc)) { return }
|
||||||
|
await fetchDocumentPath(doc)
|
||||||
previewDocument.value = doc
|
previewDocument.value = doc
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ resolvePieceType(row.piece) }}</td>
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.piece.typePiece?.id"
|
||||||
|
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolvePieceType(row.piece) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -189,11 +198,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
@@ -203,15 +212,28 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
|
|||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||||
|
|
||||||
// Pagination state
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const currentPage = ref(1)
|
const {
|
||||||
const itemsPerPage = ref(30)
|
page: currentPage,
|
||||||
|
perPage: itemsPerPage,
|
||||||
|
q: searchTerm,
|
||||||
|
sort: sortField,
|
||||||
|
dir: sortDirection,
|
||||||
|
} = useUrlState({
|
||||||
|
page: { default: 1, type: 'number' },
|
||||||
|
perPage: { default: 20, type: 'number' },
|
||||||
|
q: { default: '', debounce: 300 },
|
||||||
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => fetchPieces(),
|
||||||
|
})
|
||||||
|
|
||||||
const piecesTotal = computed(() => total.value)
|
const piecesTotal = computed(() => total.value)
|
||||||
const piecesOnPage = computed(() => pieces.value.length)
|
const piecesOnPage = computed(() => pieces.value.length)
|
||||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
// Search state with debounce
|
// Search debounce for API calls
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
@@ -224,12 +246,6 @@ const debouncedSearch = () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort state
|
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
|
||||||
'pieces-catalog',
|
|
||||||
{ field: 'name', direction: 'asc' },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enrichir les pièces avec les types de pièces complets
|
// Enrichir les pièces avec les types de pièces complets
|
||||||
const piecesList = computed(() => {
|
const piecesList = computed(() => {
|
||||||
return (pieces.value || []).map((piece) => {
|
return (pieces.value || []).map((piece) => {
|
||||||
@@ -247,7 +263,8 @@ const fetchPieces = async () => {
|
|||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: sortField.value,
|
||||||
orderDir: sortDirection.value
|
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||||
|
force: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
Ajustez les informations de la pièce et ses champs personnalisés.
|
Ajustez les informations de la pièce et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -516,7 +516,7 @@ const router = useRouter()
|
|||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { updatePiece } = usePieces()
|
const { updatePiece } = usePieces()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
@@ -750,20 +750,23 @@ const fetchPiece = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
piece.value = result.data
|
piece.value = result.data
|
||||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
|
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||||
piece.value.customFieldValues = customValues.data
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
}
|
|
||||||
await loadPieceTypeDetails(result.data)
|
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||||
await loadHistory(result.data.id)
|
loadPieceTypeDetailsFromCache(result.data)
|
||||||
|
|
||||||
|
// History is non-blocking — template handles its own loading state
|
||||||
|
loadHistory(result.data.id).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
pieceDocuments.value = []
|
pieceDocuments.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPieceTypeDetails = async (currentPiece: any) => {
|
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||||
const typeId = currentPiece?.typePieceId
|
const typeId = currentPiece?.typePieceId
|
||||||
|| extractRelationId(currentPiece?.typePiece)
|
|| extractRelationId(currentPiece?.typePiece)
|
||||||
|| ''
|
|| ''
|
||||||
@@ -771,15 +774,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
|
|||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||||
const type = await getModelType(typeId)
|
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||||
|
if (cachedType) {
|
||||||
|
pieceTypeDetails.value = cachedType
|
||||||
|
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: fetch if not in cache (edge case)
|
||||||
|
getModelType(typeId).then((type) => {
|
||||||
if (type && typeof type === 'object') {
|
if (type && typeof type === 'object') {
|
||||||
pieceTypeDetails.value = type
|
pieceTypeDetails.value = type
|
||||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
}).catch(() => {
|
||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
@@ -827,7 +837,10 @@ watch(
|
|||||||
pendingProductIds = []
|
pendingProductIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
|
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
||||||
|
// the stale destructured currentType which was captured before the ID change.
|
||||||
|
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
||||||
|
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
@@ -838,9 +851,7 @@ watch(selectedType, (currentType) => {
|
|||||||
if (!piece.value || !currentType) {
|
if (!piece.value || !currentType) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!pieceTypeDetails.value) {
|
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(resolvedStructure, (currentStructure) => {
|
watch(resolvedStructure, (currentStructure) => {
|
||||||
@@ -920,8 +931,5 @@ const submitEdition = async () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (piece.value?.id) {
|
|
||||||
await refreshDocuments()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
|||||||
@@ -110,7 +110,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="font-medium">{{ row.product.name }}</td>
|
<td class="font-medium">{{ row.product.name }}</td>
|
||||||
<td>{{ row.product.reference || '—' }}</td>
|
<td>{{ row.product.reference || '—' }}</td>
|
||||||
<td>{{ row.product.typeProduct?.name || '—' }}</td>
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.product.typeProduct?.id"
|
||||||
|
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ row.product.typeProduct.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div
|
<div
|
||||||
v-if="row.suppliers.visible.length"
|
v-if="row.suppliers.visible.length"
|
||||||
@@ -161,12 +170,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useHead } from '#imports'
|
import { useHead } from '#imports'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
@@ -186,11 +195,11 @@ const {
|
|||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
q: { default: '', debounce: 300 },
|
||||||
'product-catalog',
|
sort: { default: 'name' },
|
||||||
{ field: 'name', direction: 'asc' },
|
dir: { default: 'asc' },
|
||||||
)
|
})
|
||||||
|
|
||||||
// Enrichir les produits avec les types de produits complets
|
// Enrichir les produits avec les types de produits complets
|
||||||
const normalizedProducts = computed(() => {
|
const normalizedProducts = computed(() => {
|
||||||
@@ -379,7 +388,7 @@ const resolvePreviewAlt = (product: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
await loadProducts({ force: true })
|
await loadProducts({ itemsPerPage: 200, force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -400,7 +409,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadProducts(),
|
loadProducts({ itemsPerPage: 200, force: true }),
|
||||||
loadProductTypes()
|
loadProductTypes()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
Mettez à jour les informations du produit et ses champs personnalisés.
|
Mettez à jour les informations du produit et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -428,7 +428,7 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { getProduct, updateProduct } = useProducts()
|
const { getProduct, updateProduct } = useProducts()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const {
|
const {
|
||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments: uploadProductDocuments,
|
uploadDocuments: uploadProductDocuments,
|
||||||
@@ -520,15 +520,17 @@ const loadProduct = async () => {
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
product.value = result.data
|
product.value = result.data
|
||||||
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
||||||
|
|
||||||
await loadProductType()
|
await loadProductType()
|
||||||
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||||
product.value.customFieldValues = customValues.data
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
}
|
|
||||||
await hydrateForm()
|
hydrateForm()
|
||||||
await refreshDocuments()
|
|
||||||
await loadHistory(result.data.id)
|
// History is non-blocking — template handles its own loading state
|
||||||
|
loadHistory(result.data.id).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
product.value = null
|
product.value = null
|
||||||
}
|
}
|
||||||
@@ -587,9 +589,20 @@ const handleFilesAdded = async (files: File[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadProductType = async () => {
|
const loadProductType = async () => {
|
||||||
|
// Try using the expanded typeProduct from entity response first
|
||||||
|
const embedded = product.value?.typeProduct
|
||||||
|
if (embedded && typeof embedded === 'object' && embedded.id) {
|
||||||
|
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
|
||||||
|
if (embeddedStructure) {
|
||||||
|
productType.value = embedded
|
||||||
|
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!product.value?.typeProductId) {
|
if (!product.value?.typeProductId) {
|
||||||
productType.value = product.value?.typeProduct ?? null
|
productType.value = embedded ?? null
|
||||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -598,12 +611,12 @@ const loadProductType = async () => {
|
|||||||
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement du type de produit:', error)
|
console.error('Erreur lors du chargement du type de produit:', error)
|
||||||
productType.value = product.value?.typeProduct ?? null
|
productType.value = embedded ?? null
|
||||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateForm = async () => {
|
const hydrateForm = () => {
|
||||||
if (!product.value) {
|
if (!product.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -618,7 +631,8 @@ const hydrateForm = async () => {
|
|||||||
: ''
|
: ''
|
||||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||||
if (editionForm.constructeurIds.length) {
|
if (editionForm.constructeurIds.length) {
|
||||||
await ensureConstructeurs(editionForm.constructeurIds)
|
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
|
||||||
|
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
|||||||
@@ -132,28 +132,19 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
if (params.category) {
|
if (params.category) {
|
||||||
query.category = params.category;
|
query.category = params.category;
|
||||||
}
|
}
|
||||||
if (params.sort) {
|
|
||||||
query.sort = params.sort;
|
|
||||||
}
|
|
||||||
if (params.dir) {
|
|
||||||
query.dir = params.dir;
|
|
||||||
}
|
|
||||||
const hasCategoryFilter = Boolean(params.category);
|
|
||||||
const effectiveLimit = typeof params.limit === 'number' ? params.limit : undefined;
|
|
||||||
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
|
||||||
|
|
||||||
if (hasCategoryFilter) {
|
// Sort: API Platform OrderFilter uses order[field]=direction
|
||||||
// Fetch enough items to allow client-side category filtering + pagination.
|
const sortField = params.sort || 'name';
|
||||||
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
|
const sortDir = params.dir || 'asc';
|
||||||
query.offset = 0;
|
query[`order[${sortField}]`] = sortDir;
|
||||||
} else {
|
|
||||||
if (typeof params.limit === 'number') {
|
// Pagination: API Platform uses page + itemsPerPage
|
||||||
query.itemsPerPage = params.limit;
|
const effectiveLimit = typeof params.limit === 'number' ? params.limit : 20;
|
||||||
}
|
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
||||||
if (typeof params.offset === 'number') {
|
const page = Math.floor(effectiveOffset / effectiveLimit) + 1;
|
||||||
query.offset = params.offset;
|
|
||||||
}
|
query.itemsPerPage = effectiveLimit;
|
||||||
}
|
query.page = page;
|
||||||
|
|
||||||
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -168,25 +159,20 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
: Array.isArray(payload?.items)
|
: Array.isArray(payload?.items)
|
||||||
? payload.items
|
? payload.items
|
||||||
: [];
|
: [];
|
||||||
const filteredItems = params.category
|
|
||||||
? rawItems.filter((item: any) => item?.category === params.category)
|
const total = typeof payload?.totalItems === 'number'
|
||||||
: rawItems;
|
? payload.totalItems
|
||||||
const total = params.category
|
: typeof payload?.['hydra:totalItems'] === 'number'
|
||||||
? filteredItems.length
|
? payload['hydra:totalItems']
|
||||||
: typeof payload?.totalItems === 'number'
|
: rawItems.length;
|
||||||
? payload.totalItems
|
|
||||||
: Array.isArray(payload?.items)
|
const items = rawItems.map(normalizeModelType);
|
||||||
? payload.items.length
|
|
||||||
: rawItems.length;
|
|
||||||
const items = (params.category && typeof effectiveLimit === 'number'
|
|
||||||
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
|
|
||||||
: filteredItems).map(normalizeModelType);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
offset: effectiveOffset,
|
offset: effectiveOffset,
|
||||||
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
|
limit: effectiveLimit,
|
||||||
} satisfies ModelTypeListResponse;
|
} satisfies ModelTypeListResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
|||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
})).then(normalizeModelType);
|
})).then(normalizeModelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConversionCheck {
|
||||||
|
canConvert: boolean;
|
||||||
|
direction: 'piece_to_component' | 'component_to_piece' | null;
|
||||||
|
itemCount: number;
|
||||||
|
names: string[];
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionResult {
|
||||||
|
success: boolean;
|
||||||
|
convertedCount: number;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkConversion(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ConversionCheck>(`${ENDPOINT}/${id}/conversion-check`, createOptions({
|
||||||
|
method: 'GET',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertCategory(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ConversionResult>(`${ENDPOINT}/${id}/convert`, createOptions({
|
||||||
|
method: 'POST',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user