3 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
6 changed files with 289 additions and 6 deletions

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

View File

@@ -69,6 +69,16 @@ const badgeClass = (type: ChangeType) => {
}
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',

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