Compare commits
18 Commits
v1.3.0
...
6bed715b7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bed715b7f | ||
|
|
dbf8c8856b | ||
|
|
62127a33f5 | ||
|
|
2fffe4a368 | ||
|
|
c9054e5b4d | ||
|
|
5cab15422d | ||
|
|
439db8117a | ||
|
|
675820532c | ||
|
|
4edfc55c37 | ||
|
|
480aaa24b2 | ||
|
|
185af65519 | ||
|
|
8fecf67a7f | ||
|
|
79d2df8bc6 | ||
|
|
23da4ba4c7 | ||
|
|
635b8f0461 | ||
|
|
bf74a50f57 | ||
|
|
7c44778f25 | ||
|
|
9f7dd12b34 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -22,3 +22,8 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Playwright
|
||||
e2e/.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
|
||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||
<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>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -6,26 +6,31 @@
|
||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||
color-scheme: light; /* color of browser-provided UI */
|
||||
|
||||
--color-base-100: oklch(98% 0.02 240);
|
||||
--color-base-200: oklch(95% 0.03 240);
|
||||
--color-base-300: oklch(92% 0.04 240);
|
||||
--color-base-content: oklch(20% 0.05 240);
|
||||
--color-primary: oklch(55% 0.3 240);
|
||||
--color-primary-content: oklch(98% 0.01 240);
|
||||
--color-secondary: oklch(70% 0.25 200);
|
||||
--color-secondary-content: oklch(98% 0.01 200);
|
||||
--color-accent: oklch(65% 0.25 160);
|
||||
--color-accent-content: oklch(98% 0.01 160);
|
||||
--color-neutral: oklch(50% 0.05 240);
|
||||
--color-neutral-content: oklch(98% 0.01 240);
|
||||
--color-info: oklch(70% 0.2 220);
|
||||
--color-info-content: oklch(98% 0.01 220);
|
||||
--color-success: oklch(65% 0.25 140);
|
||||
--color-success-content: oklch(98% 0.01 140);
|
||||
--color-warning: oklch(80% 0.25 80);
|
||||
--color-warning-content: oklch(20% 0.05 80);
|
||||
--color-error: oklch(65% 0.3 30);
|
||||
--color-error-content: oklch(98% 0.01 30);
|
||||
/* #FBFAFA — gris clair */
|
||||
--color-base-100: oklch(98% 0.003 0);
|
||||
--color-base-200: oklch(94% 0.01 262);
|
||||
--color-base-300: oklch(90% 0.02 262);
|
||||
--color-base-content: oklch(20% 0.03 262);
|
||||
/* #304998 — bleu Malio */
|
||||
--color-primary: oklch(37% 0.15 262);
|
||||
--color-primary-content: oklch(98% 0.005 262);
|
||||
/* #A5ACD0 — lavande */
|
||||
--color-secondary: oklch(75% 0.055 270);
|
||||
--color-secondary-content: oklch(20% 0.03 270);
|
||||
/* #ED8521 — orange */
|
||||
--color-accent: oklch(71% 0.17 58);
|
||||
--color-accent-content: oklch(98% 0.005 58);
|
||||
/* neutral dérivé du bleu Malio */
|
||||
--color-neutral: oklch(37% 0.08 262);
|
||||
--color-neutral-content: oklch(98% 0.005 262);
|
||||
--color-info: oklch(55% 0.12 262);
|
||||
--color-info-content: oklch(98% 0.005 262);
|
||||
--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 */
|
||||
--radius-selector: 1rem;
|
||||
@@ -114,7 +119,7 @@
|
||||
|
||||
/* Focus visible pour l'accessibilité */
|
||||
*:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline: 2px solid #304998;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -275,11 +275,12 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
activePaths: ['/sites', '/documents', '/constructeurs'],
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ 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"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
:category="selectedCategory"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@convert="openConversionModal"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
|
||||
<ModelTypesConversionModal
|
||||
:open="conversionModalOpen"
|
||||
:model-type="conversionTarget"
|
||||
@close="closeConversionModal"
|
||||
@converted="onConverted"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
@@ -92,11 +101,13 @@
|
||||
</template>
|
||||
|
||||
<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 ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import { useUrlState } from "~/composables/useUrlState";
|
||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||
import {
|
||||
deleteModelType,
|
||||
@@ -125,11 +136,28 @@ const props = withDefaults(
|
||||
|
||||
const selectedCategory = ref<ModelCategory>(props.category);
|
||||
const searchInput = ref("");
|
||||
const searchTerm = ref("");
|
||||
const sort = ref<"name" | "createdAt">("name");
|
||||
const dir = ref<"asc" | "desc">("asc");
|
||||
const limit = ref(20);
|
||||
const offset = ref(0);
|
||||
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const urlState = useUrlState({
|
||||
q: { default: '' },
|
||||
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 total = ref(0);
|
||||
@@ -466,6 +494,26 @@ const closeRelatedModal = () => {
|
||||
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(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -48,6 +48,15 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</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)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -78,6 +87,15 @@
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</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)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -118,6 +136,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import IconLucideInbox from '~icons/lucide/inbox';
|
||||
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -126,15 +145,21 @@ const props = defineProps<{
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: ModelCategory;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'related', item: ModelType): void;
|
||||
(e: 'edit', item: ModelType): void;
|
||||
(e: 'delete', item: ModelType): void;
|
||||
(e: 'convert', item: ModelType): void;
|
||||
(e: 'update:offset', offset: number): void;
|
||||
}>();
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||
);
|
||||
|
||||
const categoryDictionary: Record<ModelCategory, string> = {
|
||||
COMPONENT: 'Composants',
|
||||
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
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const composants = ref<Composant[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
@@ -98,15 +100,31 @@ export function useComposants() {
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
@@ -124,6 +142,7 @@ export function useComposants() {
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
composants.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -216,15 +235,23 @@ export function useComposants() {
|
||||
const getComposants = () => composants.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
const clearComposantsCache = () => {
|
||||
composants.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
composants,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadComposants,
|
||||
createComposant,
|
||||
updateComposant: updateComposantData,
|
||||
deleteComposant,
|
||||
getComposants,
|
||||
isLoading,
|
||||
clearComposantsCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,11 +49,12 @@ export function useDocuments() {
|
||||
|
||||
const loadFromEndpoint = async (
|
||||
endpoint: string,
|
||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
||||
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get(endpoint)
|
||||
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||
const result = await get(url)
|
||||
if (result.success) {
|
||||
const data = extractCollection(result.data)
|
||||
if (updateStore) {
|
||||
@@ -76,9 +77,9 @@ export function useDocuments() {
|
||||
}
|
||||
|
||||
const loadDocuments = async (
|
||||
options: { updateStore?: boolean } = {},
|
||||
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||
): Promise<DocumentResult> => {
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||
}
|
||||
|
||||
const loadDocumentsBySite = async (
|
||||
|
||||
@@ -34,7 +34,7 @@ export function useMachineCreatePage() {
|
||||
// Composable calls
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
@@ -340,17 +340,24 @@ export function useMachineCreatePage() {
|
||||
: await createMachineFromType(baseMachineData as any, type)
|
||||
|
||||
if (result.success) {
|
||||
if (hasRequirements && result.data?.id) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
||||
const machineId = result.data?.id
|
||||
if (hasRequirements && machineId) {
|
||||
const skeletonResult: any = await reconfigureSkeleton(machineId, {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks,
|
||||
} as any)
|
||||
if (!skeletonResult.success) {
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
||||
// Rollback: delete the orphaned machine
|
||||
await deleteMachine(machineId).catch(() => {})
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants. La machine n\'a pas été créée.')
|
||||
return
|
||||
}
|
||||
}
|
||||
// Initialize custom fields for the machine type
|
||||
if (machineId) {
|
||||
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
|
||||
}
|
||||
newMachine.name = ''
|
||||
newMachine.siteId = ''
|
||||
newMachine.typeMachineId = ''
|
||||
@@ -386,9 +393,9 @@ export function useMachineCreatePage() {
|
||||
await Promise.all([
|
||||
loadSites(),
|
||||
loadMachineTypes(),
|
||||
loadComposants(),
|
||||
loadPieces(),
|
||||
loadProducts(),
|
||||
loadComposants({ itemsPerPage: 200, force: true }),
|
||||
loadPieces({ itemsPerPage: 200, force: true }),
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -41,11 +41,13 @@ interface LoadPiecesOptions {
|
||||
itemsPerPage?: number
|
||||
orderBy?: string
|
||||
orderDir?: 'asc' | 'desc'
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
const pieces = ref<Piece[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||
const p = payload as Record<string, unknown> | null
|
||||
@@ -108,15 +110,31 @@ export function usePieces() {
|
||||
}
|
||||
|
||||
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
|
||||
try {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
@@ -134,6 +152,7 @@ export function usePieces() {
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
pieces.value = enrichedItems
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
@@ -226,15 +245,23 @@ export function usePieces() {
|
||||
const getPieces = () => pieces.value
|
||||
const isLoading = () => loading.value
|
||||
|
||||
const clearPiecesCache = () => {
|
||||
pieces.value = []
|
||||
total.value = 0
|
||||
loaded.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
pieces,
|
||||
total,
|
||||
loading,
|
||||
loaded,
|
||||
loadPieces,
|
||||
createPiece,
|
||||
updatePiece: updatePieceData,
|
||||
deletePiece,
|
||||
getPieces,
|
||||
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>
|
||||
182
app/pages/changelog.vue
Normal file
182
app/pages/changelog.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<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.1',
|
||||
date: '2026-02-12',
|
||||
changes: [
|
||||
{ type: 'feat', text: 'Suivi d\'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents' },
|
||||
{ type: 'feat', text: 'Traçabilité des conversions de catégories dans le journal d\'activité (action « convert » avec direction, nombre et noms des éléments)' },
|
||||
{ type: 'feat', text: 'Endpoint historique machine : GET /api/machines/{id}/history' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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>{{ component.name || 'Composant sans nom' }}</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>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
@@ -167,11 +176,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
@@ -181,15 +190,28 @@ const { composants, total, loadComposants, loading: loadingComposantsRef, delete
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(30)
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const {
|
||||
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 composantsOnPage = computed(() => composants.value.length)
|
||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
// Search state with debounce
|
||||
const searchTerm = ref('')
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
@@ -202,12 +224,6 @@ const debouncedSearch = () => {
|
||||
}, 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
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
@@ -225,7 +241,8 @@ const fetchComposants = async () => {
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.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.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<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 { products, loadProducts } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
@@ -764,12 +764,10 @@ const fetchComponent = async () => {
|
||||
component.value = result.data
|
||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
|
||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
component.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadHistory(result.data.id)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
@@ -805,7 +803,9 @@ watch(
|
||||
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
|
||||
},
|
||||
@@ -1130,14 +1130,15 @@ onMounted(async () => {
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
loadPieces({ itemsPerPage: 500 }),
|
||||
loadProducts({ itemsPerPage: 500, force: true }),
|
||||
loadComposants({ itemsPerPage: 500 }),
|
||||
fetchComponent(),
|
||||
])
|
||||
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>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatFrenchDate } from '~/utils/date'
|
||||
@@ -139,14 +141,17 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||
|
||||
const { documents, loading, loadDocuments } = useDocuments()
|
||||
const { get } = useApi()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const attachmentFilter = ref('all')
|
||||
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
filter: { default: 'all' },
|
||||
})
|
||||
const previewDocument = ref(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
loadDocuments({ itemsPerPage: 200 })
|
||||
})
|
||||
|
||||
const filteredDocuments = computed(() => {
|
||||
@@ -156,10 +161,10 @@ const filteredDocuments = computed(() => {
|
||||
return documents.value.filter((document) => {
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'site' && document.siteId) ||
|
||||
(filter === 'machine' && document.machineId) ||
|
||||
(filter === 'composant' && document.composantId) ||
|
||||
(filter === 'piece' && document.pieceId)
|
||||
(filter === 'site' && document.site) ||
|
||||
(filter === 'machine' && document.machine) ||
|
||||
(filter === 'composant' && document.composant) ||
|
||||
(filter === 'piece' && document.piece)
|
||||
|
||||
if (!matchesFilter) { return false }
|
||||
|
||||
@@ -192,22 +197,36 @@ const formatSize = (size) => {
|
||||
|
||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||
|
||||
const downloadDocument = (doc) => {
|
||||
if (!doc?.path) { return }
|
||||
/** Fetch the full document (with path) from the API on demand. */
|
||||
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')
|
||||
link.href = doc.path
|
||||
link.href = path
|
||||
link.download = doc.filename || doc.name || 'document'
|
||||
link.click()
|
||||
return
|
||||
}
|
||||
|
||||
window.open(doc.path, '_blank')
|
||||
window.open(path, '_blank')
|
||||
}
|
||||
|
||||
const openPreview = (doc) => {
|
||||
const openPreview = async (doc) => {
|
||||
if (!canPreviewDocument(doc)) { return }
|
||||
await fetchDocumentPath(doc)
|
||||
previewDocument.value = doc
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -152,7 +152,16 @@
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</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>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
@@ -189,11 +198,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
@@ -203,15 +212,28 @@ const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = us
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(30)
|
||||
// State synced with URL query params (preserved on back/forward navigation)
|
||||
const {
|
||||
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 piecesOnPage = computed(() => pieces.value.length)
|
||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
// Search state with debounce
|
||||
const searchTerm = ref('')
|
||||
// Search debounce for API calls
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
@@ -224,12 +246,6 @@ const debouncedSearch = () => {
|
||||
}, 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
|
||||
const piecesList = computed(() => {
|
||||
return (pieces.value || []).map((piece) => {
|
||||
@@ -247,7 +263,8 @@ const fetchPieces = async () => {
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value
|
||||
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||
force: true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -516,7 +516,7 @@ const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updatePiece } = usePieces()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
@@ -750,20 +750,23 @@ const fetchPiece = async () => {
|
||||
if (result.success) {
|
||||
piece.value = result.data
|
||||
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)) {
|
||||
piece.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadPieceTypeDetails(result.data)
|
||||
await loadHistory(result.data.id)
|
||||
|
||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||
loadPieceTypeDetailsFromCache(result.data)
|
||||
|
||||
// History is non-blocking — template handles its own loading state
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||
const typeId = currentPiece?.typePieceId
|
||||
|| extractRelationId(currentPiece?.typePiece)
|
||||
|| ''
|
||||
@@ -771,15 +774,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||
pieceTypeDetails.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const type = await getModelType(typeId)
|
||||
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||
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') {
|
||||
pieceTypeDetails.value = type
|
||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||
}
|
||||
} catch (_error) {
|
||||
}).catch(() => {
|
||||
pieceTypeDetails.value = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
@@ -827,7 +837,10 @@ watch(
|
||||
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
|
||||
},
|
||||
@@ -838,9 +851,7 @@ watch(selectedType, (currentType) => {
|
||||
if (!piece.value || !currentType) {
|
||||
return
|
||||
}
|
||||
if (!pieceTypeDetails.value) {
|
||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||
}
|
||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
watch(resolvedStructure, (currentStructure) => {
|
||||
@@ -920,8 +931,5 @@ const submitEdition = async () => {
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||
loading.value = false
|
||||
if (piece.value?.id) {
|
||||
await refreshDocuments()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||
|
||||
@@ -110,7 +110,16 @@
|
||||
</td>
|
||||
<td class="font-medium">{{ row.product.name }}</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>
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
@@ -161,12 +170,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useHead } from '#imports'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
@@ -186,11 +195,11 @@ const {
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const toast = useToast()
|
||||
|
||||
const searchTerm = ref('')
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'product-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
||||
q: { default: '', debounce: 300 },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
})
|
||||
|
||||
// Enrichir les produits avec les types de produits complets
|
||||
const normalizedProducts = computed(() => {
|
||||
@@ -379,7 +388,7 @@ const resolvePreviewAlt = (product: Record<string, any>) => {
|
||||
}
|
||||
|
||||
const reload = async () => {
|
||||
await loadProducts({ force: true })
|
||||
await loadProducts({ itemsPerPage: 200, force: true })
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
@@ -400,7 +409,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadProducts(),
|
||||
loadProducts({ itemsPerPage: 200, force: true }),
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||
Retour au catalogue
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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.
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
@@ -428,7 +428,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { getProduct, updateProduct } = useProducts()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
@@ -520,15 +520,17 @@ const loadProduct = async () => {
|
||||
if (result.success && result.data) {
|
||||
product.value = result.data
|
||||
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
||||
|
||||
await loadProductType()
|
||||
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
product.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await hydrateForm()
|
||||
await refreshDocuments()
|
||||
await loadHistory(result.data.id)
|
||||
|
||||
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||
refreshCustomFieldInputs(undefined, customValues)
|
||||
|
||||
hydrateForm()
|
||||
|
||||
// History is non-blocking — template handles its own loading state
|
||||
loadHistory(result.data.id).catch(() => {})
|
||||
} else {
|
||||
product.value = null
|
||||
}
|
||||
@@ -587,9 +589,20 @@ const handleFilesAdded = async (files: File[]) => {
|
||||
}
|
||||
|
||||
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) {
|
||||
productType.value = product.value?.typeProduct ?? null
|
||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
||||
productType.value = embedded ?? null
|
||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -598,12 +611,12 @@ const loadProductType = async () => {
|
||||
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du type de produit:', error)
|
||||
productType.value = product.value?.typeProduct ?? null
|
||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
||||
productType.value = embedded ?? null
|
||||
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
const hydrateForm = async () => {
|
||||
const hydrateForm = () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
@@ -618,7 +631,8 @@ const hydrateForm = async () => {
|
||||
: ''
|
||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
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.
|
||||
</p>
|
||||
</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
|
||||
</NuxtLink>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<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) {
|
||||
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) {
|
||||
// Fetch enough items to allow client-side category filtering + pagination.
|
||||
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
|
||||
query.offset = 0;
|
||||
} else {
|
||||
if (typeof params.limit === 'number') {
|
||||
query.itemsPerPage = params.limit;
|
||||
}
|
||||
if (typeof params.offset === 'number') {
|
||||
query.offset = params.offset;
|
||||
}
|
||||
}
|
||||
// Sort: API Platform OrderFilter uses order[field]=direction
|
||||
const sortField = params.sort || 'name';
|
||||
const sortDir = params.dir || 'asc';
|
||||
query[`order[${sortField}]`] = sortDir;
|
||||
|
||||
// Pagination: API Platform uses page + itemsPerPage
|
||||
const effectiveLimit = typeof params.limit === 'number' ? params.limit : 20;
|
||||
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
||||
const page = Math.floor(effectiveOffset / effectiveLimit) + 1;
|
||||
|
||||
query.itemsPerPage = effectiveLimit;
|
||||
query.page = page;
|
||||
|
||||
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
||||
method: 'GET',
|
||||
@@ -168,25 +159,20 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
||||
: Array.isArray(payload?.items)
|
||||
? payload.items
|
||||
: [];
|
||||
const filteredItems = params.category
|
||||
? rawItems.filter((item: any) => item?.category === params.category)
|
||||
: rawItems;
|
||||
const total = params.category
|
||||
? filteredItems.length
|
||||
: typeof payload?.totalItems === 'number'
|
||||
? payload.totalItems
|
||||
: Array.isArray(payload?.items)
|
||||
? payload.items.length
|
||||
: rawItems.length;
|
||||
const items = (params.category && typeof effectiveLimit === 'number'
|
||||
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
|
||||
: filteredItems).map(normalizeModelType);
|
||||
|
||||
const total = typeof payload?.totalItems === 'number'
|
||||
? payload.totalItems
|
||||
: typeof payload?.['hydra:totalItems'] === 'number'
|
||||
? payload['hydra:totalItems']
|
||||
: rawItems.length;
|
||||
|
||||
const items = rawItems.map(normalizeModelType);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
offset: effectiveOffset,
|
||||
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
|
||||
limit: effectiveLimit,
|
||||
} satisfies ModelTypeListResponse;
|
||||
}
|
||||
|
||||
@@ -233,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||
signal: opts.signal,
|
||||
})).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,
|
||||
}));
|
||||
}
|
||||
|
||||
35
e2e/auth.setup.ts
Normal file
35
e2e/auth.setup.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
|
||||
const AUTH_FILE = 'e2e/.auth/session.json'
|
||||
|
||||
/**
|
||||
* Authentication setup: selects the first available profile
|
||||
* to establish a session cookie before running any test.
|
||||
*
|
||||
* The app uses a profile-based session system:
|
||||
* - GET /api/session/profiles → list available profiles
|
||||
* - POST /api/session/profile → activate a profile (sets cookie)
|
||||
*
|
||||
* The global middleware (profile.global.ts) redirects to /profiles
|
||||
* if no active profile is found.
|
||||
*/
|
||||
setup('select a profile to authenticate', async ({ page }) => {
|
||||
// Go to the profiles page
|
||||
await page.goto('/profiles')
|
||||
|
||||
// Wait for profiles to load
|
||||
await expect(page.getByRole('heading', { name: 'Choisir un profil' })).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Wait for at least one profile button to appear
|
||||
const profileButton = page.locator('button.btn-outline').first()
|
||||
await expect(profileButton).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Click the first available profile
|
||||
await profileButton.click()
|
||||
|
||||
// Wait for redirect to home page (profile selected → session cookie set)
|
||||
await page.waitForURL('/', { timeout: 10_000 })
|
||||
|
||||
// Save authenticated state (cookies + localStorage)
|
||||
await page.context().storageState({ path: AUTH_FILE })
|
||||
})
|
||||
166
e2e/product-category-crud.spec.ts
Normal file
166
e2e/product-category-crud.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* E2E tests for Product Category CRUD operations.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Frontend running on http://localhost:3001 (npm run dev)
|
||||
* - Backend running on http://localhost:8081 (docker compose up)
|
||||
* - Auth setup must run first (profile selected)
|
||||
*/
|
||||
|
||||
const UNIQUE = Date.now()
|
||||
const CATEGORY_NAME = `E2E Catégorie Produit ${UNIQUE}`
|
||||
const CATEGORY_NOTES = `Notes de test automatisé ${UNIQUE}`
|
||||
const CATEGORY_NAME_UPDATED = `${CATEGORY_NAME} modifié`
|
||||
const CATEGORY_NOTES_UPDATED = `${CATEGORY_NOTES} — mis à jour`
|
||||
|
||||
test.describe('Product Category CRUD', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// CREATE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should display the product category list page', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
await expect(page.getByRole('heading', { name: 'Catégories de produit' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('Catégories enregistrées')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate to the create form', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
// The toolbar button text is "Créer" (with a plus icon)
|
||||
await page.getByRole('button', { name: /créer/i }).click()
|
||||
await expect(page).toHaveURL('/product-category/new')
|
||||
await expect(page.getByRole('heading', { name: 'Nouvelle catégorie de produit' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show validation error for short name', async ({ page }) => {
|
||||
await page.goto('/product-category/new')
|
||||
await page.locator('#model-type-name').fill('A')
|
||||
// The form submit button in ModelTypeForm is also "Créer"
|
||||
await page.locator('button[type="submit"]').click()
|
||||
await expect(page.getByText('Le nom doit contenir au moins 2 caractères')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should create a new product category', async ({ page }) => {
|
||||
await page.goto('/product-category/new')
|
||||
await page.locator('#model-type-name').fill(CATEGORY_NAME)
|
||||
await page.locator('#model-type-notes').fill(CATEGORY_NOTES)
|
||||
|
||||
// Verify category is locked to PRODUCT
|
||||
const categorySelect = page.locator('#model-type-category')
|
||||
await expect(categorySelect).toBeDisabled()
|
||||
await expect(categorySelect).toHaveValue('PRODUCT')
|
||||
|
||||
await page.locator('button[type="submit"]').click()
|
||||
|
||||
// Should redirect to list and show success toast
|
||||
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
|
||||
await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible()
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// READ
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should display the created category in the list', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
// Target the table cell specifically (desktop view also renders a mobile card)
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('should find the category via search', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
|
||||
// Type in search input (placeholder: "Rechercher par nom…")
|
||||
const searchInput = page.getByPlaceholder('Rechercher par nom…')
|
||||
await searchInput.fill(UNIQUE.toString())
|
||||
// Wait for debounce (300ms) + API response
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// UPDATE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should navigate to the edit page', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Find the row with our category and click "Éditer"
|
||||
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME })
|
||||
await row.getByRole('button', { name: 'Éditer' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('should edit the category name and notes', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME })
|
||||
await row.getByRole('button', { name: 'Éditer' }).click()
|
||||
await expect(page.getByRole('heading', { name: /modifier/i })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Update name
|
||||
const nameInput = page.locator('#model-type-name')
|
||||
await nameInput.clear()
|
||||
await nameInput.fill(CATEGORY_NAME_UPDATED)
|
||||
|
||||
// Update notes
|
||||
const notesTextarea = page.locator('#model-type-notes')
|
||||
await notesTextarea.clear()
|
||||
await notesTextarea.fill(CATEGORY_NOTES_UPDATED)
|
||||
|
||||
await page.locator('button[type="submit"]').click()
|
||||
|
||||
// Should redirect and show success
|
||||
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
|
||||
await expect(page.getByText('Catégorie de produit mise à jour avec succès')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display updated category in the list', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// DELETE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should cancel deletion when clicking Annuler', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED })
|
||||
await row.getByRole('button', { name: 'Supprimer' }).click()
|
||||
|
||||
// Confirmation modal should appear
|
||||
await expect(page.getByText('Supprimer ce type ?')).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Annuler' }).click()
|
||||
|
||||
// Category should still be present
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should delete the category', async ({ page }) => {
|
||||
await page.goto('/product-category')
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: CATEGORY_NAME_UPDATED })
|
||||
await row.getByRole('button', { name: 'Supprimer' }).click()
|
||||
|
||||
// Confirm deletion in modal
|
||||
await expect(page.getByText('Supprimer ce type ?')).toBeVisible()
|
||||
// Click the confirm "Supprimer" button inside the modal (btn-error style)
|
||||
await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click()
|
||||
|
||||
// Should show success toast and category should disappear
|
||||
await expect(page.getByText(/supprimé avec succès/i)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('cell', { name: CATEGORY_NAME_UPDATED })).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
299
e2e/product-crud.spec.ts
Normal file
299
e2e/product-crud.spec.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* E2E tests for Product CRUD operations.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Frontend running on http://localhost:3001 (npm run dev)
|
||||
* - Backend running on http://localhost:8081 (docker compose up)
|
||||
* - Auth setup must run first (profile selected)
|
||||
*
|
||||
* These tests create a temporary product category, use it to test
|
||||
* the full product CRUD, then clean up both.
|
||||
*/
|
||||
|
||||
const UNIQUE = Date.now()
|
||||
const TEST_CATEGORY_NAME = `E2E Cat Produit ${UNIQUE}`
|
||||
const PRODUCT_NAME = `E2E Produit Test ${UNIQUE}`
|
||||
const PRODUCT_REFERENCE = `REF-E2E-${UNIQUE}`
|
||||
const PRODUCT_PRICE = '42.50'
|
||||
const PRODUCT_NAME_UPDATED = `${PRODUCT_NAME} modifié`
|
||||
const PRODUCT_REFERENCE_UPDATED = `${PRODUCT_REFERENCE}-UPD`
|
||||
const PRODUCT_PRICE_UPDATED = '99.99'
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates a product category via the UI.
|
||||
*/
|
||||
async function createTestCategory(page: Page) {
|
||||
await page.goto('/product-category/new')
|
||||
await page.locator('#model-type-name').fill(TEST_CATEGORY_NAME)
|
||||
await page.locator('button[type="submit"]').click()
|
||||
await expect(page).toHaveURL('/product-category', { timeout: 10_000 })
|
||||
await expect(page.getByText('Catégorie de produit créée avec succès')).toBeVisible()
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects an option in a SearchSelect component.
|
||||
*
|
||||
* The SearchSelect renders:
|
||||
* .search-select > .relative > input[placeholder]
|
||||
* .search-select > .relative > div (dropdown) > ul > li > button
|
||||
*/
|
||||
async function selectSearchOption(page: Page, placeholder: string, searchText: string) {
|
||||
const input = page.getByPlaceholder(placeholder)
|
||||
await input.click()
|
||||
await input.fill(searchText)
|
||||
|
||||
// The dropdown is inside .search-select > .relative > div > ul > li > button
|
||||
const option = page.locator('.search-select ul li button')
|
||||
.filter({ hasText: searchText })
|
||||
.first()
|
||||
await option.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await option.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up test data: deletes the category via the UI.
|
||||
*/
|
||||
async function cleanupTestCategory(page: Page) {
|
||||
await page.goto('/product-category')
|
||||
// Wait for list to load
|
||||
await page.waitForTimeout(1_000)
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: TEST_CATEGORY_NAME })
|
||||
if (await row.isVisible().catch(() => false)) {
|
||||
await row.getByRole('button', { name: 'Supprimer' }).click()
|
||||
const confirmBtn = page.locator('button.btn-error').filter({ hasText: 'Supprimer' })
|
||||
await confirmBtn.waitFor({ state: 'visible', timeout: 5_000 })
|
||||
await confirmBtn.click()
|
||||
await page.waitForTimeout(1_000)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Product CRUD', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// SETUP: Create a test category
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('setup: create a test product category', async ({ page }) => {
|
||||
await createTestCategory(page)
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// LIST PAGE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should display the product catalog page', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByRole('link', { name: /ajouter un produit/i })).toBeVisible()
|
||||
await expect(page.getByRole('link', { name: /gérer les catégories/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should navigate to create product page', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await page.getByRole('link', { name: /ajouter un produit/i }).click()
|
||||
await expect(page).toHaveURL('/product/create')
|
||||
await expect(page.getByRole('heading', { name: 'Nouveau produit' })).toBeVisible()
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// CREATE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should show disabled fields until category is selected', async ({ page }) => {
|
||||
await page.goto('/product/create')
|
||||
|
||||
// Name input should be disabled before selecting a category
|
||||
const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue')
|
||||
await expect(nameInput).toBeDisabled()
|
||||
})
|
||||
|
||||
test('should create a product with all fields', async ({ page }) => {
|
||||
await page.goto('/product/create')
|
||||
|
||||
// 1. Select category via SearchSelect
|
||||
await selectSearchOption(page, 'Rechercher une catégorie...', TEST_CATEGORY_NAME)
|
||||
|
||||
// Wait for form to enable after category selection
|
||||
const nameInput = page.getByPlaceholder('Nom affiché dans le catalogue')
|
||||
await expect(nameInput).toBeEnabled({ timeout: 5_000 })
|
||||
|
||||
// 2. Fill form fields
|
||||
await nameInput.clear()
|
||||
await nameInput.fill(PRODUCT_NAME)
|
||||
|
||||
const referenceInput = page.getByPlaceholder('Référence interne ou fournisseur')
|
||||
await referenceInput.fill(PRODUCT_REFERENCE)
|
||||
|
||||
const priceInput = page.getByPlaceholder('Valeur indicatrice')
|
||||
await priceInput.fill(PRODUCT_PRICE)
|
||||
|
||||
// 3. Submit
|
||||
await page.getByRole('button', { name: /créer le produit/i }).click()
|
||||
|
||||
// Should redirect to catalog and show success
|
||||
await expect(page).toHaveURL('/product-catalog', { timeout: 15_000 })
|
||||
await expect(page.getByText('Produit créé avec succès')).toBeVisible()
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// READ
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should display the created product in the catalog', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('should show product reference in the catalog', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('should find the product via search', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
|
||||
const searchInput = page.getByPlaceholder('Nom ou référence…')
|
||||
await searchInput.fill(PRODUCT_REFERENCE)
|
||||
// Wait for client-side filtering
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
await expect(page.getByText(PRODUCT_NAME)).toBeVisible()
|
||||
await expect(page.getByText(PRODUCT_REFERENCE)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show category link in the catalog', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
|
||||
await expect(row.getByText(TEST_CATEGORY_NAME)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should sort products by name', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await page.locator('#product-sort').selectOption('name')
|
||||
await page.locator('#product-dir').selectOption('asc')
|
||||
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should sort products by creation date', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await page.locator('#product-sort').selectOption('createdAt')
|
||||
await page.locator('#product-dir').selectOption('desc')
|
||||
await expect(page.getByRole('heading', { name: 'Catalogue des produits' })).toBeVisible()
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// UPDATE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should navigate to edit page from catalog', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
|
||||
await row.getByRole('link', { name: 'Modifier' }).click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('should show category note on edit page', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
|
||||
await row.getByRole('link', { name: 'Modifier' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Category should be displayed but disabled
|
||||
await expect(page.getByText("La catégorie d'origine ne peut pas être modifiée")).toBeVisible()
|
||||
})
|
||||
|
||||
test('should update the product', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME })
|
||||
await row.getByRole('link', { name: 'Modifier' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Modifier le produit' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
// Update name (label-text "Nom du produit" → sibling input)
|
||||
const nameInput = page.locator('.form-control').filter({ hasText: 'Nom du produit' }).locator('input')
|
||||
await nameInput.clear()
|
||||
await nameInput.fill(PRODUCT_NAME_UPDATED)
|
||||
|
||||
// Update reference
|
||||
const refInput = page.locator('.form-control').filter({ hasText: 'Référence' }).locator('input')
|
||||
await refInput.clear()
|
||||
await refInput.fill(PRODUCT_REFERENCE_UPDATED)
|
||||
|
||||
// Update price
|
||||
const priceInput = page.locator('.form-control').filter({ hasText: 'Prix fournisseur' }).locator('input')
|
||||
await priceInput.clear()
|
||||
await priceInput.fill(PRODUCT_PRICE_UPDATED)
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: /enregistrer les modifications/i }).click()
|
||||
|
||||
await expect(page).toHaveURL('/product-catalog', { timeout: 10_000 })
|
||||
await expect(page.getByText('Produit mis à jour avec succès')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should display updated product in catalog', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText(PRODUCT_REFERENCE_UPDATED)).toBeVisible()
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// DELETE
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('should cancel product deletion', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED })
|
||||
await row.getByRole('button', { name: 'Supprimer' }).click()
|
||||
|
||||
// Confirmation modal
|
||||
await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Annuler' }).click()
|
||||
|
||||
// Product should still be here
|
||||
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible()
|
||||
})
|
||||
|
||||
test('should delete the product', async ({ page }) => {
|
||||
await page.goto('/product-catalog')
|
||||
await expect(page.getByText(PRODUCT_NAME_UPDATED)).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const row = page.getByRole('row').filter({ hasText: PRODUCT_NAME_UPDATED })
|
||||
await row.getByRole('button', { name: 'Supprimer' }).click()
|
||||
|
||||
// Confirm deletion in modal
|
||||
await expect(page.getByText(/voulez-vous vraiment supprimer/i)).toBeVisible()
|
||||
await page.locator('button.btn-error').filter({ hasText: 'Supprimer' }).click()
|
||||
|
||||
await expect(page.getByText(/supprimé/i)).toBeVisible({ timeout: 10_000 })
|
||||
// The toast message contains the product name, so check the table specifically
|
||||
const table = page.locator('table')
|
||||
await expect(table.getByText(PRODUCT_NAME_UPDATED)).not.toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// CLEANUP: Remove the test category
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
test('cleanup: delete the test product category', async ({ page }) => {
|
||||
await cleanupTestCategory(page)
|
||||
})
|
||||
})
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.68",
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@rushstack/eslint-patch": "^1.12.0",
|
||||
"@types/node": "^25.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
@@ -3202,6 +3203,22 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@polka/url": {
|
||||
"version": "1.0.0-next.29",
|
||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||
@@ -10828,6 +10845,53 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pluralize": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
"lint": "eslint . --ext .js,.ts,.vue",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
@@ -26,6 +29,7 @@
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.68",
|
||||
"@nuxt/eslint-config": "^1.9.0",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@rushstack/eslint-patch": "^1.12.0",
|
||||
"@types/node": "^25.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||
|
||||
37
playwright.config.ts
Normal file
37
playwright.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const AUTH_FILE = 'e2e/.auth/session.json'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
timeout: 30_000,
|
||||
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Auth setup: selects a profile to get a session cookie
|
||||
{
|
||||
name: 'auth-setup',
|
||||
testMatch: /auth\.setup\.ts/,
|
||||
},
|
||||
|
||||
// All tests run after auth setup, with the saved session
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: AUTH_FILE,
|
||||
},
|
||||
dependencies: ['auth-setup'],
|
||||
},
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user