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>
This commit is contained in:
172
app/components/model-types/ConversionModal.vue
Normal file
172
app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||||
|
<div class="modal-box max-w-2xl">
|
||||||
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
|
Convertir la catégorie
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||||
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Vérification de la conversion…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error state -->
|
||||||
|
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||||
|
{{ checkError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Blocked state -->
|
||||||
|
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||||
|
<p class="mt-3 text-sm text-base-content/70">
|
||||||
|
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="(blocker, i) in checkResult.blockers"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||||
|
>
|
||||||
|
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
{{ blocker }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Eligible state -->
|
||||||
|
<template v-else-if="checkResult && checkResult.canConvert">
|
||||||
|
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||||
|
<p class="text-sm font-medium text-warning">
|
||||||
|
{{ directionLabel }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-sm text-base-content/70">
|
||||||
|
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="checkResult.names.length > 0"
|
||||||
|
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||||
|
>
|
||||||
|
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||||
|
Éléments concernés :
|
||||||
|
</p>
|
||||||
|
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||||
|
<li
|
||||||
|
v-for="(name, i) in checkResult.names"
|
||||||
|
:key="i"
|
||||||
|
class="py-1.5 text-sm text-base-content"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||||
|
{{ convertError }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="checkResult?.canConvert"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-warning"
|
||||||
|
:disabled="converting"
|
||||||
|
@click="doConvert"
|
||||||
|
>
|
||||||
|
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||||
|
import {
|
||||||
|
checkConversion,
|
||||||
|
convertCategory,
|
||||||
|
type ConversionCheck,
|
||||||
|
type ModelType,
|
||||||
|
} from '~/services/modelTypes';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
modelType: ModelType | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
(e: 'converted'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const checking = ref(false);
|
||||||
|
const checkError = ref<string | null>(null);
|
||||||
|
const checkResult = ref<ConversionCheck | null>(null);
|
||||||
|
const converting = ref(false);
|
||||||
|
const convertError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const directionLabel = computed(() => {
|
||||||
|
if (!checkResult.value) return '';
|
||||||
|
return checkResult.value.direction === 'piece_to_component'
|
||||||
|
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||||
|
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
async (isOpen) => {
|
||||||
|
if (!isOpen || !props.modelType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
checking.value = true;
|
||||||
|
checkError.value = null;
|
||||||
|
checkResult.value = null;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
checkResult.value = await checkConversion(props.modelType.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
checkError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const doConvert = async () => {
|
||||||
|
if (!props.modelType) return;
|
||||||
|
|
||||||
|
converting.value = true;
|
||||||
|
convertError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await convertCategory(props.modelType.id);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
convertError.value = result.error || 'La conversion a échoué.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('converted');
|
||||||
|
} catch (err: any) {
|
||||||
|
convertError.value =
|
||||||
|
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||||
|
} finally {
|
||||||
|
converting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -29,12 +29,21 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
|
:category="selectedCategory"
|
||||||
@related="openRelatedModal"
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
|
@convert="openConversionModal"
|
||||||
@update:offset="onOffsetChange"
|
@update:offset="onOffsetChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelTypesConversionModal
|
||||||
|
:open="conversionModalOpen"
|
||||||
|
:model-type="conversionTarget"
|
||||||
|
@close="closeConversionModal"
|
||||||
|
@converted="onConverted"
|
||||||
|
/>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||||
<div class="modal-box max-w-3xl">
|
<div class="modal-box max-w-3xl">
|
||||||
<h3 class="text-lg font-bold text-base-content">
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
@@ -96,6 +105,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue"
|
|||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from "#imports";
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
|
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||||
import { useApi } from "~/composables/useApi";
|
import { useApi } from "~/composables/useApi";
|
||||||
import { useUrlState } from "~/composables/useUrlState";
|
import { useUrlState } from "~/composables/useUrlState";
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||||
@@ -484,6 +494,26 @@ const closeRelatedModal = () => {
|
|||||||
relatedModalOpen.value = false;
|
relatedModalOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const conversionModalOpen = ref(false);
|
||||||
|
const conversionTarget = ref<ModelType | null>(null);
|
||||||
|
|
||||||
|
const openConversionModal = (item: ModelType) => {
|
||||||
|
conversionTarget.value = item;
|
||||||
|
conversionModalOpen.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeConversionModal = () => {
|
||||||
|
conversionModalOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConverted = () => {
|
||||||
|
conversionModalOpen.value = false;
|
||||||
|
invalidateEntityTypeCache("PIECE");
|
||||||
|
invalidateEntityTypeCache("COMPONENT");
|
||||||
|
showSuccess("Catégorie convertie avec succès.");
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -48,6 +48,15 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -78,6 +87,15 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||||
Liés
|
Liés
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="showConvertButton"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-sm text-warning"
|
||||||
|
@click="emit('convert', item)"
|
||||||
|
>
|
||||||
|
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Convertir
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -118,6 +136,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import IconLucideInbox from '~icons/lucide/inbox';
|
import IconLucideInbox from '~icons/lucide/inbox';
|
||||||
|
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -126,15 +145,21 @@ const props = defineProps<{
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
category?: ModelCategory;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'related', item: ModelType): void;
|
(e: 'related', item: ModelType): void;
|
||||||
(e: 'edit', item: ModelType): void;
|
(e: 'edit', item: ModelType): void;
|
||||||
(e: 'delete', item: ModelType): void;
|
(e: 'delete', item: ModelType): void;
|
||||||
|
(e: 'convert', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): void;
|
(e: 'update:offset', offset: number): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const showConvertButton = computed(() =>
|
||||||
|
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||||
|
);
|
||||||
|
|
||||||
const categoryDictionary: Record<ModelCategory, string> = {
|
const categoryDictionary: Record<ModelCategory, string> = {
|
||||||
COMPONENT: 'Composants',
|
COMPONENT: 'Composants',
|
||||||
PIECE: 'Pièces',
|
PIECE: 'Pièces',
|
||||||
|
|||||||
@@ -219,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
|||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
})).then(normalizeModelType);
|
})).then(normalizeModelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConversionCheck {
|
||||||
|
canConvert: boolean;
|
||||||
|
direction: 'piece_to_component' | 'component_to_piece' | null;
|
||||||
|
itemCount: number;
|
||||||
|
names: string[];
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionResult {
|
||||||
|
success: boolean;
|
||||||
|
convertedCount: number;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkConversion(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ConversionCheck>(`${ENDPOINT}/${id}/conversion-check`, createOptions({
|
||||||
|
method: 'GET',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertCategory(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ConversionResult>(`${ENDPOINT}/${id}/convert`, createOptions({
|
||||||
|
method: 'POST',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user