4 Commits

Author SHA1 Message Date
Matthieu
2fffe4a368 chore(release) : update changelog for v1.6.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:27:43 +01:00
Matthieu
c9054e5b4d feat(categories): add bidirectional piece/component category conversion
Add a "Convertir" button on piece and component category lists that allows
converting an entire category (and all its items) between piece and component.
Includes a modal with eligibility checks and blocker display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:26:41 +01:00
Matthieu
5cab15422d fix(documents) : exclude path from collection to prevent OOM, lazy-load on demand
The path field contains base64 data URIs that can be several MB each.
Loading 200 documents at once exceeded the 128MB PHP memory limit.
Now the collection endpoint uses document:list group (without path)
and the frontend fetches the full document on demand when the user
clicks download or preview.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:16:15 +01:00
Matthieu
439db8117a feat(changelog) : add changelog page accessible from footer version link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 17:01:28 +01:00
7 changed files with 455 additions and 7 deletions

View File

@@ -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>

View 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>

View File

@@ -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">
@@ -96,6 +105,7 @@ 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";
@@ -484,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) => {

View File

@@ -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',

173
app/pages/changelog.vue Normal file
View File

@@ -0,0 +1,173 @@
<template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
<p class="text-sm text-base-content/70">
Historique des modifications et nouvelles fonctionnalités de l'application.
</p>
</header>
<section
v-for="release in releases"
:key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm"
>
<div class="card-body space-y-3">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">
{{ release.version }}
</h2>
<span class="badge badge-ghost text-xs">{{ release.date }}</span>
</div>
<ul class="space-y-2">
<li
v-for="(item, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm text-base-content/80"
>
<span
class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)"
>
{{ item.type }}
</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { useHead } from '#imports'
useHead({ title: 'Changelog' })
type ChangeType = 'feat' | 'fix' | 'perf' | 'chore'
interface Change {
type: ChangeType
text: string
}
interface Release {
version: string
date: string
changes: Change[]
}
const badgeClass = (type: ChangeType) => {
const map: Record<ChangeType, string> = {
feat: 'badge-primary',
fix: 'badge-error',
perf: 'badge-warning',
chore: 'badge-ghost',
}
return map[type] ?? 'badge-ghost'
}
const releases: Release[] = [
{
version: 'v1.6.0',
date: '2026-02-12',
changes: [
{ type: 'feat', text: 'Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs' },
{ type: 'feat', text: 'Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms' },
{ type: 'feat', text: 'Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée' },
{ type: 'chore', text: 'Passage php-cs-fixer sur l\'ensemble des contrôleurs et entités du backend' },
],
},
{
version: 'v1.5.0',
date: '2026-02-11',
changes: [
{ type: 'feat', text: 'Page de journal d\'activité globale avec filtres par entité, par acteur et pagination serveur' },
{ type: 'feat', text: 'Suivi d\'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés' },
{ type: 'feat', text: 'Préservation de l\'état des listes dans l\'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente' },
{ type: 'feat', text: 'Boutons « Retour » sur toutes les pages de création et d\'édition utilisent désormais l\'historique du navigateur au lieu de liens fixes' },
{ type: 'feat', text: 'Première lettre automatiquement en majuscule lors de la création de catégories et de composants' },
{ type: 'feat', text: 'Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d\'édition)' },
{ type: 'feat', text: 'Application des couleurs de marque Malio sur l\'ensemble du thème (navbar, boutons, badges)' },
{ type: 'feat', text: 'Page changelog accessible depuis le footer' },
{ type: 'fix', text: 'Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits' },
{ type: 'fix', text: 'Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents' },
{ type: 'fix', text: 'Correction de l\'affichage des champs personnalisés sur les pages d\'édition (condition de concurrence)' },
{ type: 'fix', text: 'Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production' },
{ type: 'perf', text: 'Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement' },
{ type: 'perf', text: 'Réduction des appels API bloquants sur les pages d\'édition' },
],
},
{
version: 'v1.4.0',
date: '2026-02-04',
changes: [
{ type: 'perf', text: 'Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses' },
{ type: 'perf', text: 'Pages d\'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel' },
],
},
{
version: 'v1.3.0',
date: '2026-01-28',
changes: [
{ type: 'feat', text: 'Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)' },
{ type: 'feat', text: 'Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants' },
{ type: 'feat', text: 'Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants' },
{ type: 'feat', text: 'Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)' },
{ type: 'feat', text: 'Fusion des composables dupliqués : 3 composables d\'historique et 3 composables de types fusionnés en versions génériques' },
{ type: 'feat', text: 'Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l\'ensemble de l\'application' },
{ type: 'feat', text: 'Extraction de la navbar dans un composant AppNavbar dédié' },
{ type: 'feat', text: 'Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables' },
{ type: 'perf', text: 'Optimisations API : helper extractCollection partagé, invalidation de cache ciblée' },
{ type: 'chore', text: 'Migration des composables JavaScript vers TypeScript strict' },
{ type: 'chore', text: 'Activation de règles ESLint strictes et suppression de 19 console.log de débogage' },
],
},
{
version: 'v1.2.0',
date: '2026-01-21',
changes: [
{ type: 'feat', text: 'Système de suivi d\'historique (audit) avec enregistrement automatique des modifications sur toutes les entités' },
{ type: 'feat', text: 'Interface dédiée à l\'historique sur les fiches produits, pièces et composants' },
{ type: 'feat', text: 'Modale d\'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d\'édition' },
{ type: 'feat', text: 'Possibilité d\'ajouter des champs personnalisés en mode restreint sur les catégories' },
],
},
{
version: 'v1.1.1',
date: '2026-01-14',
changes: [
{ type: 'feat', text: 'Compression automatique des fichiers PDF à l\'upload via qpdf, réduisant l\'espace de stockage' },
{ type: 'chore', text: 'Ajout de qpdf dans l\'image Docker pour le support de la compression PDF' },
],
},
{
version: 'v1.1.0',
date: '2026-01-07',
changes: [
{ type: 'fix', text: 'Recherche insensible à la casse sur l\'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)' },
{ type: 'chore', text: 'Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement' },
{ type: 'chore', text: 'Mise à jour des fixtures avec les données courantes de la base' },
],
},
{
version: 'v1.0.0',
date: '2025-12-15',
changes: [
{ type: 'feat', text: 'Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces' },
{ type: 'feat', text: 'Catalogues composants, pièces et produits avec recherche serveur, tri et pagination' },
{ type: 'feat', text: 'Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner' },
{ type: 'feat', text: 'Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux' },
{ type: 'feat', text: 'Gestion des fournisseurs multiples avec résolution automatique des noms' },
{ type: 'feat', text: 'Exigences produit sur les pièces : support de liaisons multiples' },
{ type: 'feat', text: 'Sélections de composants sur les pièces avec recherche dynamique' },
{ type: 'feat', text: 'Système de sessions utilisateurs avec authentification JWT' },
{ type: 'feat', text: 'Mémorisation des préférences de tri par catalogue (cookies)' },
{ type: 'feat', text: 'Formatage automatique des contacts et des montants en format français' },
{ type: 'feat', text: 'Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation' },
{ type: 'chore', text: 'Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin' },
],
},
]
</script>

View File

@@ -132,6 +132,7 @@
<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'
@@ -140,6 +141,7 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments()
const { get } = useApi()
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
q: { default: '', debounce: 300 },
@@ -195,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
}

View File

@@ -219,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,
}));
}