Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a98ab8c275 | ||
|
|
e22463874c | ||
|
|
256039264e | ||
|
|
e459da7c20 | ||
|
|
e84b5cf674 | ||
|
|
cc70fe2b29 | ||
|
|
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.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
e2e/.auth/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
|||||||
@@ -19,7 +19,9 @@
|
|||||||
|
|
||||||
<footer class="footer p-4 bg-neutral text-neutral-content">
|
<footer class="footer p-4 bg-neutral text-neutral-content">
|
||||||
<div class="items-center grid-flow-col">
|
<div class="items-center grid-flow-col">
|
||||||
<p>@Malio 2025 · v{{ appVersion }}</p>
|
<p>
|
||||||
|
@Malio 2025 · <NuxtLink to="/changelog" class="link link-hover">v{{ appVersion }}</NuxtLink>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,26 +6,31 @@
|
|||||||
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
prefersdark: false; /* set as default dark mode (prefers-color-scheme:dark) */
|
||||||
color-scheme: light; /* color of browser-provided UI */
|
color-scheme: light; /* color of browser-provided UI */
|
||||||
|
|
||||||
--color-base-100: oklch(98% 0.02 240);
|
/* #FBFAFA — gris clair */
|
||||||
--color-base-200: oklch(95% 0.03 240);
|
--color-base-100: oklch(98% 0.003 0);
|
||||||
--color-base-300: oklch(92% 0.04 240);
|
--color-base-200: oklch(94% 0.01 262);
|
||||||
--color-base-content: oklch(20% 0.05 240);
|
--color-base-300: oklch(90% 0.02 262);
|
||||||
--color-primary: oklch(55% 0.3 240);
|
--color-base-content: oklch(20% 0.03 262);
|
||||||
--color-primary-content: oklch(98% 0.01 240);
|
/* #304998 — bleu Malio */
|
||||||
--color-secondary: oklch(70% 0.25 200);
|
--color-primary: oklch(37% 0.15 262);
|
||||||
--color-secondary-content: oklch(98% 0.01 200);
|
--color-primary-content: oklch(98% 0.005 262);
|
||||||
--color-accent: oklch(65% 0.25 160);
|
/* #A5ACD0 — lavande */
|
||||||
--color-accent-content: oklch(98% 0.01 160);
|
--color-secondary: oklch(75% 0.055 270);
|
||||||
--color-neutral: oklch(50% 0.05 240);
|
--color-secondary-content: oklch(20% 0.03 270);
|
||||||
--color-neutral-content: oklch(98% 0.01 240);
|
/* #ED8521 — orange */
|
||||||
--color-info: oklch(70% 0.2 220);
|
--color-accent: oklch(71% 0.17 58);
|
||||||
--color-info-content: oklch(98% 0.01 220);
|
--color-accent-content: oklch(98% 0.005 58);
|
||||||
--color-success: oklch(65% 0.25 140);
|
/* neutral dérivé du bleu Malio */
|
||||||
--color-success-content: oklch(98% 0.01 140);
|
--color-neutral: oklch(37% 0.08 262);
|
||||||
--color-warning: oklch(80% 0.25 80);
|
--color-neutral-content: oklch(98% 0.005 262);
|
||||||
--color-warning-content: oklch(20% 0.05 80);
|
--color-info: oklch(55% 0.12 262);
|
||||||
--color-error: oklch(65% 0.3 30);
|
--color-info-content: oklch(98% 0.005 262);
|
||||||
--color-error-content: oklch(98% 0.01 30);
|
--color-success: oklch(65% 0.2 145);
|
||||||
|
--color-success-content: oklch(98% 0.005 145);
|
||||||
|
--color-warning: oklch(78% 0.15 70);
|
||||||
|
--color-warning-content: oklch(20% 0.05 70);
|
||||||
|
--color-error: oklch(60% 0.25 25);
|
||||||
|
--color-error-content: oklch(98% 0.005 25);
|
||||||
|
|
||||||
/* border radius */
|
/* border radius */
|
||||||
--radius-selector: 1rem;
|
--radius-selector: 1rem;
|
||||||
@@ -114,7 +119,7 @@
|
|||||||
|
|
||||||
/* Focus visible pour l'accessibilité */
|
/* Focus visible pour l'accessibilité */
|
||||||
*:focus-visible {
|
*:focus-visible {
|
||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #304998;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
212
app/components/CommentSection.vue
Normal file
212
app/components/CommentSection.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<IconLucideMessageSquare class="w-5 h-5" />
|
||||||
|
Commentaires
|
||||||
|
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
||||||
|
{{ openComments.length }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
v-if="showResolved && resolvedComments.length"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="showResolvedList = !showResolvedList"
|
||||||
|
>
|
||||||
|
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire d'ajout -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="newContent"
|
||||||
|
class="textarea textarea-bordered flex-1 text-sm"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Ajouter un commentaire..."
|
||||||
|
:disabled="submitting"
|
||||||
|
@keydown.ctrl.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm self-end"
|
||||||
|
:disabled="!newContent.trim() || submitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
<IconLucideSend v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des commentaires ouverts -->
|
||||||
|
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
||||||
|
Aucun commentaire ouvert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="comment in openComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200 rounded-lg p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
|
<span>
|
||||||
|
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="canEdit" class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-xs gap-1"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleResolve(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideCheck class="w-3 h-3" />
|
||||||
|
Résoudre
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleDelete(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash2 class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commentaires résolus -->
|
||||||
|
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
||||||
|
<div class="divider text-xs text-base-content/40">
|
||||||
|
Résolus
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="comment in resolvedComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||||
|
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||||
|
<span v-if="comment.resolvedByName">
|
||||||
|
Résolu par {{ comment.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||||
|
import IconLucideSend from '~icons/lucide/send'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string
|
||||||
|
showResolved?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const newContent = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const loadingComments = ref(false)
|
||||||
|
const showResolvedList = ref(false)
|
||||||
|
|
||||||
|
const openComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'open'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'resolved'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loadingComments.value = true
|
||||||
|
const [openResult, resolvedResult] = await Promise.all([
|
||||||
|
fetchComments(props.entityType, props.entityId, 'open'),
|
||||||
|
props.showResolved
|
||||||
|
? fetchComments(props.entityType, props.entityId, 'resolved')
|
||||||
|
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
||||||
|
])
|
||||||
|
const open = openResult.success ? (openResult.data ?? []) : []
|
||||||
|
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
||||||
|
comments.value = [...open, ...resolved]
|
||||||
|
loadingComments.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const content = newContent.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
submitting.value = true
|
||||||
|
const result = await createComment(
|
||||||
|
props.entityType,
|
||||||
|
props.entityId,
|
||||||
|
content,
|
||||||
|
props.entityName,
|
||||||
|
)
|
||||||
|
submitting.value = false
|
||||||
|
if (result.success) {
|
||||||
|
newContent.value = ''
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: string) => {
|
||||||
|
const result = await deleteComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.entityId) {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -20,16 +20,16 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="openDropdown"
|
v-if="openDropdown"
|
||||||
class="absolute z-20 mt-1 w-full max-h-48 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="options.length === 0"
|
v-if="filteredOptions.length === 0"
|
||||||
class="px-3 py-2 text-xs text-gray-500"
|
class="px-3 py-2 text-xs text-gray-500"
|
||||||
>
|
>
|
||||||
Aucun fournisseur trouvé
|
Aucun fournisseur trouvé
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="option in options"
|
v-for="option in filteredOptions"
|
||||||
:key="option.id"
|
:key="option.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||||
@@ -164,8 +164,7 @@ const openCreateModal = ref(false)
|
|||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const options = ref<ConstructeurSummary[]>([])
|
const options = ref<ConstructeurSummary[]>([])
|
||||||
const selectedIds = ref<string[]>([])
|
const selectedIds = ref<string[]>([])
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
||||||
let lastSearchTerm = ''
|
|
||||||
|
|
||||||
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const seen = new Map<string, ConstructeurSummary>()
|
const seen = new Map<string, ConstructeurSummary>()
|
||||||
@@ -182,32 +181,22 @@ const normalizedInitialOptions = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
||||||
const normalized = uniqueOptions([
|
options.value = uniqueOptions([
|
||||||
...normalizedInitialOptions.value,
|
...normalizedInitialOptions.value,
|
||||||
...items,
|
...items,
|
||||||
])
|
])
|
||||||
const limited = normalized.slice(0, 10)
|
|
||||||
|
|
||||||
selectedIds.value.forEach((id) => {
|
|
||||||
if (!limited.some((item) => item.id === id)) {
|
|
||||||
const match =
|
|
||||||
normalized.find((item) => item.id === id) ||
|
|
||||||
constructeurs.value.find((item) => item.id === id)
|
|
||||||
if (match) {
|
|
||||||
if (limited.length >= 10) {
|
|
||||||
limited.pop()
|
|
||||||
}
|
|
||||||
limited.unshift(match)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
options.value = uniqueOptions([
|
|
||||||
...normalizedInitialOptions.value,
|
|
||||||
...limited,
|
|
||||||
])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const term = searchTerm.value.trim().toLowerCase()
|
||||||
|
if (!term) return options.value
|
||||||
|
return options.value.filter((option) =>
|
||||||
|
(option.name ?? '').toLowerCase().includes(term)
|
||||||
|
|| (option.email && option.email.toLowerCase().includes(term))
|
||||||
|
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const createForm = ref({
|
const createForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -257,46 +246,20 @@ const extractDataArray = (data: unknown): ConstructeurSummary[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ensureOptionsLoaded = async (force = false) => {
|
const ensureOptionsLoaded = async (force = false) => {
|
||||||
if (!force && !searchTerm.value && constructeurs.value.length) {
|
if (!force && constructeurs.value.length) {
|
||||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!force && searchTerm.value === lastSearchTerm && options.value.length) {
|
const result = await searchConstructeurs('')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.value.length && !force) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
applyOptions(extractDataArray(result.data))
|
applyOptions(extractDataArray(result.data))
|
||||||
lastSearchTerm = searchTerm.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSearch = () => {
|
const onSearch = () => {
|
||||||
openDropdown.value = true
|
openDropdown.value = true
|
||||||
if (searchTimeout) {
|
ensureOptionsLoaded()
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
searchTimeout = setTimeout(async () => {
|
|
||||||
if (!searchTerm.value && constructeurs.value.length) {
|
|
||||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
|
||||||
lastSearchTerm = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (searchTerm.value === lastSearchTerm) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = await searchConstructeurs(searchTerm.value)
|
|
||||||
if (result.success) {
|
|
||||||
applyOptions(extractDataArray(result.data))
|
|
||||||
lastSearchTerm = searchTerm.value
|
|
||||||
}
|
|
||||||
}, 250)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleOption = (option: ConstructeurSummary) => {
|
const toggleOption = (option: ConstructeurSummary) => {
|
||||||
@@ -319,9 +282,19 @@ const closeCreateModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
|
const trimmedName = createForm.value.name.trim()
|
||||||
|
const duplicate = options.value.find(
|
||||||
|
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
emitSelection([...selectedIds.value, duplicate.id])
|
||||||
|
closeCreateModal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
creating.value = true
|
creating.value = true
|
||||||
const payload: { name: string; email?: string; phone?: string } = {
|
const payload: { name: string; email?: string; phone?: string } = {
|
||||||
name: createForm.value.name,
|
name: trimmedName,
|
||||||
}
|
}
|
||||||
if (createForm.value.email) {
|
if (createForm.value.email) {
|
||||||
payload.email = createForm.value.email
|
payload.email = createForm.value.email
|
||||||
@@ -383,9 +356,6 @@ watch(
|
|||||||
constructeurs,
|
constructeurs,
|
||||||
(list) => {
|
(list) => {
|
||||||
applyOptions((list as ConstructeurSummary[]) || [])
|
applyOptions((list as ConstructeurSummary[]) || [])
|
||||||
if (!searchTerm.value) {
|
|
||||||
lastSearchTerm = ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
@@ -405,9 +375,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('click', clickHandler)
|
window.removeEventListener('click', clickHandler)
|
||||||
if (searchTimeout) {
|
|
||||||
clearTimeout(searchTimeout)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -55,16 +55,16 @@
|
|||||||
</select>
|
</select>
|
||||||
|
|
||||||
<!-- Champ de type BOOLEAN -->
|
<!-- Champ de type BOOLEAN -->
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="fieldValues[field.id]"
|
v-model="fieldValues[field.id]"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm"
|
||||||
:checked="fieldValues[field.id] === 'true'"
|
:checked="fieldValues[field.id] === 'true'"
|
||||||
@change="updateCustomFieldValue(field.id)"
|
@change="updateCustomFieldValue(field.id)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<!-- Champ de type DATE -->
|
<!-- Champ de type DATE -->
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -65,6 +65,9 @@
|
|||||||
:class="childLinkClass(child)"
|
:class="childLinkClass(child)"
|
||||||
>
|
>
|
||||||
{{ child.label }}
|
{{ child.label }}
|
||||||
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -142,6 +145,9 @@
|
|||||||
:class="childLinkClass(child)"
|
:class="childLinkClass(child)"
|
||||||
>
|
>
|
||||||
{{ child.label }}
|
{{ child.label }}
|
||||||
|
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -166,8 +172,14 @@
|
|||||||
<div
|
<div
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
role="button"
|
role="button"
|
||||||
class="btn btn-ghost btn-circle avatar placeholder"
|
class="btn btn-ghost btn-circle avatar placeholder indicator"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
v-if="unresolvedCount > 0"
|
||||||
|
class="indicator-item badge badge-warning badge-xs"
|
||||||
|
>
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
<div
|
<div
|
||||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
||||||
>
|
>
|
||||||
@@ -185,11 +197,21 @@
|
|||||||
<li class="px-2 py-1 text-sm text-base-content/70">
|
<li class="px-2 py-1 text-sm text-base-content/70">
|
||||||
Connecté en tant que<br />
|
Connecté en tant que<br />
|
||||||
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
<span class="font-semibold text-base-content">{{ activeProfileLabel }}</span>
|
||||||
|
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||||
|
</li>
|
||||||
|
<li v-if="isAdmin">
|
||||||
|
<NuxtLink to="/admin" class="justify-between">
|
||||||
|
Administration
|
||||||
|
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||||
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<NuxtLink to="/profiles/manage" class="justify-between">
|
<NuxtLink to="/comments" class="justify-between">
|
||||||
Gestion des profils
|
Commentaires
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||||
|
{{ unresolvedCount }}
|
||||||
|
</span>
|
||||||
|
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -211,10 +233,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute } from '#imports'
|
import { useRoute } from '#imports'
|
||||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
import { useProfileSession } from '~/composables/useProfileSession'
|
import { useProfileSession } from '~/composables/useProfileSession'
|
||||||
|
import { useComments } from '~/composables/useComments'
|
||||||
import IconLucideMenu from '~icons/lucide/menu'
|
import IconLucideMenu from '~icons/lucide/menu'
|
||||||
import IconLucideSettings from '~icons/lucide/settings'
|
import IconLucideSettings from '~icons/lucide/settings'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||||
@@ -275,11 +299,13 @@ const navGroups: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'resources',
|
id: 'resources',
|
||||||
label: 'Ressources liées',
|
label: 'Ressources liées',
|
||||||
activePaths: ['/sites', '/documents', '/constructeurs'],
|
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||||
children: [
|
children: [
|
||||||
{ to: '/sites', label: 'Sites' },
|
{ to: '/sites', label: 'Sites' },
|
||||||
{ to: '/documents', label: 'Documents' },
|
{ to: '/documents', label: 'Documents' },
|
||||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||||
|
{ to: '/comments', label: 'Commentaires' },
|
||||||
|
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -287,6 +313,25 @@ const navGroups: NavGroup[] = [
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||||
const { activeProfile } = useProfileSession()
|
const { activeProfile } = useProfileSession()
|
||||||
|
const { isAdmin, canEdit } = usePermissions()
|
||||||
|
const { fetchUnresolvedCount } = useComments()
|
||||||
|
|
||||||
|
const unresolvedCount = ref(0)
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const refreshUnresolvedCount = async () => {
|
||||||
|
if (!activeProfile.value) return
|
||||||
|
unresolvedCount.value = await fetchUnresolvedCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
refreshUnresolvedCount()
|
||||||
|
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
|
})
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/') {
|
if (path === '/') {
|
||||||
@@ -317,6 +362,18 @@ const childLinkClass = (child: NavLink) => {
|
|||||||
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
: 'text-base-content hover:bg-primary/10 hover:text-primary'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roleLabel = computed(() => {
|
||||||
|
if (isAdmin.value) return 'Admin'
|
||||||
|
if (canEdit.value) return 'Gestionnaire'
|
||||||
|
return 'Lecteur'
|
||||||
|
})
|
||||||
|
|
||||||
|
const roleBadgeClass = computed(() => {
|
||||||
|
if (isAdmin.value) return 'badge-error'
|
||||||
|
if (canEdit.value) return 'badge-warning'
|
||||||
|
return 'badge-info'
|
||||||
|
})
|
||||||
|
|
||||||
const activeProfileLabel = computed(() => {
|
const activeProfileLabel = computed(() => {
|
||||||
if (!activeProfile.value) {
|
if (!activeProfile.value) {
|
||||||
return 'Profil inconnu'
|
return 'Profil inconnu'
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ import IconLucideSquarePen from '~icons/lucide/square-pen'
|
|||||||
import IconLucideEye from '~icons/lucide/eye'
|
import IconLucideEye from '~icons/lucide/eye'
|
||||||
import IconLucidePrinter from '~icons/lucide/printer'
|
import IconLucidePrinter from '~icons/lucide/printer'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
isDetailsView: boolean
|
isDetailsView: boolean
|
||||||
|
|||||||
@@ -120,17 +120,16 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
:value="field.value ?? ''"
|
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm"
|
||||||
:checked="String(field.value).toLowerCase() === 'true'"
|
:checked="String(field.value).toLowerCase() === 'true'"
|
||||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||||
@blur="$emit('update-custom-field', field)"
|
@blur="$emit('update-custom-field', field)"
|
||||||
/>
|
>
|
||||||
<span class="text-sm">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
:value="field.value ?? ''"
|
:value="field.value ?? ''"
|
||||||
|
|||||||
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>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
:dir="dir"
|
:dir="dir"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:show-category-tabs="allowCategorySwitch"
|
:show-category-tabs="allowCategorySwitch"
|
||||||
|
:can-edit="canEdit"
|
||||||
@update:category="onCategoryChange"
|
@update:category="onCategoryChange"
|
||||||
@update:search="onSearchInput"
|
@update:search="onSearchInput"
|
||||||
@update:sort="onSortChange"
|
@update:sort="onSortChange"
|
||||||
@@ -29,12 +30,22 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
|
:category="selectedCategory"
|
||||||
|
:can-edit="canEdit"
|
||||||
@related="openRelatedModal"
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
|
@convert="openConversionModal"
|
||||||
@update:offset="onOffsetChange"
|
@update:offset="onOffsetChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ModelTypesConversionModal
|
||||||
|
:open="conversionModalOpen"
|
||||||
|
:model-type="conversionTarget"
|
||||||
|
@close="closeConversionModal"
|
||||||
|
@converted="onConverted"
|
||||||
|
/>
|
||||||
|
|
||||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||||
<div class="modal-box max-w-3xl">
|
<div class="modal-box max-w-3xl">
|
||||||
<h3 class="text-lg font-bold text-base-content">
|
<h3 class="text-lg font-bold text-base-content">
|
||||||
@@ -92,11 +103,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from "vue";
|
||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from "#imports";
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
|
import ModelTypesConversionModal from "~/components/model-types/ConversionModal.vue";
|
||||||
import { useApi } from "~/composables/useApi";
|
import { useApi } from "~/composables/useApi";
|
||||||
|
import { useUrlState } from "~/composables/useUrlState";
|
||||||
import { extractCollection } from "~/shared/utils/apiHelpers";
|
import { extractCollection } from "~/shared/utils/apiHelpers";
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
@@ -125,11 +138,28 @@ const props = withDefaults(
|
|||||||
|
|
||||||
const selectedCategory = ref<ModelCategory>(props.category);
|
const selectedCategory = ref<ModelCategory>(props.category);
|
||||||
const searchInput = ref("");
|
const searchInput = ref("");
|
||||||
const searchTerm = ref("");
|
|
||||||
const sort = ref<"name" | "createdAt">("name");
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const dir = ref<"asc" | "desc">("asc");
|
const urlState = useUrlState({
|
||||||
const limit = ref(20);
|
q: { default: '' },
|
||||||
const offset = ref(0);
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
limit: { default: 20, type: 'number' },
|
||||||
|
offset: { default: 0, type: 'number' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => {
|
||||||
|
searchInput.value = urlState.q.value;
|
||||||
|
refresh();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const searchTerm = urlState.q;
|
||||||
|
const sort = urlState.sort as Ref<'name' | 'createdAt'>;
|
||||||
|
const dir = urlState.dir as Ref<'asc' | 'desc'>;
|
||||||
|
const limit = urlState.limit;
|
||||||
|
const offset = urlState.offset;
|
||||||
|
|
||||||
|
// Initialize searchInput from URL (for direct navigation with ?q=...)
|
||||||
|
searchInput.value = searchTerm.value;
|
||||||
|
|
||||||
const items = ref<ModelType[]>([]);
|
const items = ref<ModelType[]>([]);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
@@ -141,6 +171,7 @@ let activeController: AbortController | null = null;
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
const { get } = useApi();
|
const { get } = useApi();
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading);
|
||||||
const descriptionText = computed(
|
const descriptionText = computed(
|
||||||
@@ -466,6 +497,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) => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
name="category"
|
name="category"
|
||||||
required
|
required
|
||||||
:disabled="lockCategory"
|
:disabled="lockCategory || isReadonly"
|
||||||
>
|
>
|
||||||
<option value="COMPONENT">Composants</option>
|
<option value="COMPONENT">Composants</option>
|
||||||
<option value="PIECE">Pièces</option>
|
<option value="PIECE">Pièces</option>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
{{ submitLabel }}
|
{{ submitLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -176,6 +176,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disableSubmitMessage?: string
|
disableSubmitMessage?: string
|
||||||
restrictedMode?: boolean
|
restrictedMode?: boolean
|
||||||
restrictedModeMessage?: string
|
restrictedModeMessage?: string
|
||||||
|
readonly?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -187,6 +188,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disableSubmitMessage: '',
|
disableSubmitMessage: '',
|
||||||
restrictedMode: false,
|
restrictedMode: false,
|
||||||
restrictedModeMessage: '',
|
restrictedModeMessage: '',
|
||||||
|
readonly: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -209,7 +211,8 @@ const disableSubmitMessage = computed(() =>
|
|||||||
? props.disableSubmitMessage
|
? props.disableSubmitMessage
|
||||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||||
)
|
)
|
||||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
const isReadonly = computed(() => props.readonly === true)
|
||||||
|
const restrictedMode = computed(() => props.restrictedMode === true || isReadonly.value)
|
||||||
const restrictedModeMessage = computed(() =>
|
const restrictedModeMessage = computed(() =>
|
||||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||||
? props.restrictedModeMessage
|
? props.restrictedModeMessage
|
||||||
@@ -291,7 +294,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value || isReadonly.value)
|
||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
@@ -308,6 +311,7 @@ const validate = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
if (isReadonly.value) return
|
||||||
if (!validate()) {
|
if (!validate()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,10 +48,19 @@
|
|||||||
<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="canEdit && 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>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
@@ -78,10 +87,19 @@
|
|||||||
<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="canEdit && 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>
|
||||||
<button type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -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,22 @@ const props = defineProps<{
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
category?: ModelCategory;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
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',
|
||||||
|
|||||||
@@ -83,13 +83,14 @@ import type { ModelCategory } from '~/services/modelTypes';
|
|||||||
type SortField = 'name' | 'createdAt';
|
type SortField = 'name' | 'createdAt';
|
||||||
type SortDirection = 'asc' | 'desc';
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
category: ModelCategory;
|
category: ModelCategory;
|
||||||
search: string;
|
search: string;
|
||||||
sort: SortField;
|
sort: SortField;
|
||||||
dir: SortDirection;
|
dir: SortDirection;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
showCategoryTabs?: boolean;
|
showCategoryTabs?: boolean;
|
||||||
|
canEdit?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
||||||
Modifier
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click="emit('delete', site)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +55,8 @@ import IconLucidePhone from '~icons/lucide/phone'
|
|||||||
import IconLucideUser from '~icons/lucide/user'
|
import IconLucideUser from '~icons/lucide/user'
|
||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
site: {
|
site: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom et prénom"
|
placeholder="Nom et prénom"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FieldPhone v-model="contactPhone" required />
|
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Adresse complète"
|
placeholder="Adresse complète"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Code postal"
|
placeholder="Code postal"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,6 +54,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ville"
|
placeholder="Ville"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,6 +81,10 @@ const props = defineProps({
|
|||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = toRef(props, 'form')
|
const form = toRef(props, 'form')
|
||||||
|
|||||||
@@ -12,17 +12,18 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine principale"
|
placeholder="Ex: Usine principale"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="siteRef" />
|
<SiteContactFormFields :form="siteRef" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,6 +54,10 @@ const props = defineProps({
|
|||||||
site: {
|
site: {
|
||||||
type: Object as PropType<SiteForm>,
|
type: Object as PropType<SiteForm>,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div v-if="visible" class="modal modal-open">
|
<div v-if="visible" class="modal modal-open">
|
||||||
<div class="modal-box max-w-md">
|
<div class="modal-box max-w-md">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
Modifier le site
|
{{ disabled ? 'Détails du site' : 'Modifier le site' }}
|
||||||
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
<span v-if="siteName" class="block text-sm font-normal text-gray-500">{{ siteName }}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="emit('submit')">
|
<form class="space-y-4" @submit.prevent="emit('submit')">
|
||||||
@@ -15,11 +15,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Nom du site"
|
placeholder="Nom du site"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="disabled"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="props.form" />
|
<SiteContactFormFields :form="props.form" :disabled="disabled" />
|
||||||
|
|
||||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
|
v-if="!disabled"
|
||||||
v-model="selectedFilesModel"
|
v-model="selectedFilesModel"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
subtitle="Formats courants acceptés : PDF, JPG, PNG, DOCX..."
|
||||||
@@ -90,7 +92,7 @@
|
|||||||
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
<button type="button" class="btn btn-ghost btn-xs" @click="emit('download-document', document)">
|
||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
<button v-if="!disabled" type="button" class="btn btn-error btn-xs" @click="emit('remove-document', document.id)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +105,7 @@
|
|||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="uploadingDocuments">
|
<button type="submit" class="btn btn-primary" :disabled="disabled || uploadingDocuments">
|
||||||
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="uploadingDocuments" class="loading loading-spinner loading-xs mr-2" />
|
||||||
Enregistrer
|
Enregistrer
|
||||||
</button>
|
</button>
|
||||||
@@ -155,6 +157,10 @@ const props = defineProps({
|
|||||||
formatSize: {
|
formatSize: {
|
||||||
type: Function,
|
type: Function,
|
||||||
required: true
|
required: true
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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 }
|
||||||
|
}
|
||||||
80
app/composables/useAdminProfiles.ts
Normal file
80
app/composables/useAdminProfiles.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
|
||||||
|
export interface AdminProfile {
|
||||||
|
id: string
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email: string | null
|
||||||
|
isActive: boolean
|
||||||
|
hasPassword: boolean
|
||||||
|
roles: string[]
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminProfiles() {
|
||||||
|
const { get, post, put } = useApi()
|
||||||
|
const profiles = ref<AdminProfile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchAll = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await get<AdminProfile[]>('/admin/profiles')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
profiles.value = result.data
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createProfile = async (data: {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
email?: string
|
||||||
|
password?: string
|
||||||
|
role?: string
|
||||||
|
}) => {
|
||||||
|
const result = await post<AdminProfile>('/admin/profiles', data)
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRole = async (id: string, role: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/role`, { role })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const setPassword = async (id: string, password: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/password`, { password })
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const deactivateProfile = async (id: string) => {
|
||||||
|
const result = await put<AdminProfile>(`/admin/profiles/${id}/deactivate`, {})
|
||||||
|
if (result.success) {
|
||||||
|
await fetchAll()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
profiles,
|
||||||
|
loading,
|
||||||
|
fetchAll,
|
||||||
|
createProfile,
|
||||||
|
updateRole,
|
||||||
|
setPassword,
|
||||||
|
deactivateProfile,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,7 +66,9 @@ export function useApi() {
|
|||||||
const text = await response.text().catch(() => '')
|
const text = await response.text().catch(() => '')
|
||||||
errorData = text ? { message: text } : {}
|
errorData = text ? { message: text } : {}
|
||||||
}
|
}
|
||||||
const errorMessage = (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
const errorMessage = response.status === 403
|
||||||
|
? 'Permissions insuffisantes pour cette action.'
|
||||||
|
: (errorData.message as string) || `Erreur ${response.status}: ${response.statusText}`
|
||||||
showError(errorMessage)
|
showError(errorMessage)
|
||||||
return { success: false, error: errorMessage, status: response.status }
|
return { success: false, error: errorMessage, status: response.status }
|
||||||
}
|
}
|
||||||
|
|||||||
184
app/composables/useComments.ts
Normal file
184
app/composables/useComments.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string | null
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
status: 'open' | 'resolved'
|
||||||
|
resolvedById?: string | null
|
||||||
|
resolvedByName?: string | null
|
||||||
|
resolvedAt?: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment | Comment[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentListResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment[]
|
||||||
|
total?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComments() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchComments = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
status: string = 'open',
|
||||||
|
): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
status,
|
||||||
|
'order[createdAt]': 'desc',
|
||||||
|
itemsPerPage: '200',
|
||||||
|
})
|
||||||
|
const result = await get(`/comments?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection<Comment>(result.data)
|
||||||
|
return { success: true, data: items }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllComments = async (options: {
|
||||||
|
status?: string
|
||||||
|
entityType?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
} = {}): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options.status) params.set('status', options.status)
|
||||||
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
|
params.set('order[createdAt]', 'desc')
|
||||||
|
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
|
params.set('page', String(options.page || 1))
|
||||||
|
|
||||||
|
const result = await get(`/comments?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection<Comment>(result.data)
|
||||||
|
const raw = result.data as Record<string, unknown> | null
|
||||||
|
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
|
||||||
|
return { success: true, data: items, total }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComment = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
content: string,
|
||||||
|
entityName?: string,
|
||||||
|
): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = { entityType, entityId, content }
|
||||||
|
if (entityName) payload.entityName = entityName
|
||||||
|
const result = await post('/comments', payload)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire ajouté')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible d\'ajouter le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/comments/${commentId}/resolve`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire résolu')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de résoudre le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/comments/${commentId}`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire supprimé')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de supprimer le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data.count
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
fetchAllComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
fetchUnresolvedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,11 +40,13 @@ interface LoadComposantsOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const composants = ref<Composant[]>([])
|
const composants = ref<Composant[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
@@ -98,15 +100,31 @@ export function useComposants() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
const loadComposants = async (options: LoadComposantsOptions = {}): Promise<ComposantListResult> => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: composants.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
search = '',
|
|
||||||
page = 1,
|
|
||||||
itemsPerPage = 30,
|
|
||||||
orderBy = 'name',
|
|
||||||
orderDir = 'asc',
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
@@ -124,6 +142,7 @@ export function useComposants() {
|
|||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
composants.value = enrichedItems
|
composants.value = enrichedItems
|
||||||
total.value = extractTotal(result.data, items.length)
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -216,15 +235,23 @@ export function useComposants() {
|
|||||||
const getComposants = () => composants.value
|
const getComposants = () => composants.value
|
||||||
const isLoading = () => loading.value
|
const isLoading = () => loading.value
|
||||||
|
|
||||||
|
const clearComposantsCache = () => {
|
||||||
|
composants.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
composants,
|
composants,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadComposants,
|
loadComposants,
|
||||||
createComposant,
|
createComposant,
|
||||||
updateComposant: updateComposantData,
|
updateComposant: updateComposantData,
|
||||||
deleteComposant,
|
deleteComposant,
|
||||||
getComposants,
|
getComposants,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
clearComposantsCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ export function useDocuments() {
|
|||||||
|
|
||||||
const loadFromEndpoint = async (
|
const loadFromEndpoint = async (
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
{ updateStore = false }: { updateStore?: boolean } = {},
|
{ updateStore = false, itemsPerPage }: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const result = await get(endpoint)
|
const url = itemsPerPage ? `${endpoint}${endpoint.includes('?') ? '&' : '?'}itemsPerPage=${itemsPerPage}` : endpoint
|
||||||
|
const result = await get(url)
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const data = extractCollection(result.data)
|
const data = extractCollection(result.data)
|
||||||
if (updateStore) {
|
if (updateStore) {
|
||||||
@@ -76,9 +77,9 @@ export function useDocuments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadDocuments = async (
|
const loadDocuments = async (
|
||||||
options: { updateStore?: boolean } = {},
|
options: { updateStore?: boolean; itemsPerPage?: number } = {},
|
||||||
): Promise<DocumentResult> => {
|
): Promise<DocumentResult> => {
|
||||||
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true })
|
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage })
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDocumentsBySite = async (
|
const loadDocumentsBySite = async (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function useMachineCreatePage() {
|
|||||||
// Composable calls
|
// Composable calls
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
const { createMachine, createMachineFromType, reconfigureSkeleton, addMissingCustomFields, deleteMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||||
@@ -340,17 +340,24 @@ export function useMachineCreatePage() {
|
|||||||
: await createMachineFromType(baseMachineData as any, type)
|
: await createMachineFromType(baseMachineData as any, type)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
if (hasRequirements && result.data?.id) {
|
const machineId = result.data?.id
|
||||||
const skeletonResult: any = await reconfigureSkeleton(result.data.id, {
|
if (hasRequirements && machineId) {
|
||||||
|
const skeletonResult: any = await reconfigureSkeleton(machineId, {
|
||||||
componentLinks,
|
componentLinks,
|
||||||
pieceLinks,
|
pieceLinks,
|
||||||
productLinks,
|
productLinks,
|
||||||
} as any)
|
} as any)
|
||||||
if (!skeletonResult.success) {
|
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
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Initialize custom fields for the machine type
|
||||||
|
if (machineId) {
|
||||||
|
await addMissingCustomFields(machineId, { showToast: false }).catch(() => {})
|
||||||
|
}
|
||||||
newMachine.name = ''
|
newMachine.name = ''
|
||||||
newMachine.siteId = ''
|
newMachine.siteId = ''
|
||||||
newMachine.typeMachineId = ''
|
newMachine.typeMachineId = ''
|
||||||
@@ -386,9 +393,9 @@ export function useMachineCreatePage() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadSites(),
|
loadSites(),
|
||||||
loadMachineTypes(),
|
loadMachineTypes(),
|
||||||
loadComposants(),
|
loadComposants({ itemsPerPage: 200, force: true }),
|
||||||
loadPieces(),
|
loadPieces({ itemsPerPage: 200, force: true }),
|
||||||
loadProducts(),
|
loadProducts({ itemsPerPage: 200, force: true }),
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
41
app/composables/usePermissions.ts
Normal file
41
app/composables/usePermissions.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { useProfileSession } from './useProfileSession'
|
||||||
|
|
||||||
|
const ROLE_HIERARCHY: Record<string, string[]> = {
|
||||||
|
ROLE_ADMIN: ['ROLE_ADMIN', 'ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_GESTIONNAIRE: ['ROLE_GESTIONNAIRE', 'ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_VIEWER: ['ROLE_VIEWER', 'ROLE_USER'],
|
||||||
|
ROLE_USER: ['ROLE_USER'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePermissions() {
|
||||||
|
const { activeProfile } = useProfileSession()
|
||||||
|
|
||||||
|
const effectiveRoles = computed<string[]>(() => {
|
||||||
|
const roles = (activeProfile.value?.roles as string[] | undefined) ?? ['ROLE_USER']
|
||||||
|
const all = new Set<string>()
|
||||||
|
for (const role of roles) {
|
||||||
|
const inherited = ROLE_HIERARCHY[role] ?? [role]
|
||||||
|
for (const r of inherited) {
|
||||||
|
all.add(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...all]
|
||||||
|
})
|
||||||
|
|
||||||
|
const isGranted = (role: string): boolean => {
|
||||||
|
return effectiveRoles.value.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = computed(() => isGranted('ROLE_ADMIN'))
|
||||||
|
const canEdit = computed(() => isGranted('ROLE_GESTIONNAIRE'))
|
||||||
|
const canView = computed(() => isGranted('ROLE_VIEWER'))
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAdmin,
|
||||||
|
canEdit,
|
||||||
|
canView,
|
||||||
|
isGranted,
|
||||||
|
effectiveRoles,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,11 +41,13 @@ interface LoadPiecesOptions {
|
|||||||
itemsPerPage?: number
|
itemsPerPage?: number
|
||||||
orderBy?: string
|
orderBy?: string
|
||||||
orderDir?: 'asc' | 'desc'
|
orderDir?: 'asc' | 'desc'
|
||||||
|
force?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const pieces = ref<Piece[]>([])
|
const pieces = ref<Piece[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
const extractTotal = (payload: unknown, fallbackLength: number): number => {
|
||||||
const p = payload as Record<string, unknown> | null
|
const p = payload as Record<string, unknown> | null
|
||||||
@@ -108,15 +110,31 @@ export function usePieces() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
const loadPieces = async (options: LoadPiecesOptions = {}): Promise<PieceListResult> => {
|
||||||
|
const {
|
||||||
|
search = '',
|
||||||
|
page = 1,
|
||||||
|
itemsPerPage = 30,
|
||||||
|
orderBy = 'name',
|
||||||
|
orderDir = 'asc',
|
||||||
|
force = false,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
if (!force && loaded.value && !search && page === 1) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { items: pieces.value, total: total.value, page, itemsPerPage },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const {
|
|
||||||
search = '',
|
|
||||||
page = 1,
|
|
||||||
itemsPerPage = 30,
|
|
||||||
orderBy = 'name',
|
|
||||||
orderDir = 'asc',
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
params.set('itemsPerPage', String(itemsPerPage))
|
params.set('itemsPerPage', String(itemsPerPage))
|
||||||
@@ -134,6 +152,7 @@ export function usePieces() {
|
|||||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||||
pieces.value = enrichedItems
|
pieces.value = enrichedItems
|
||||||
total.value = extractTotal(result.data, items.length)
|
total.value = extractTotal(result.data, items.length)
|
||||||
|
loaded.value = true
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
@@ -226,15 +245,23 @@ export function usePieces() {
|
|||||||
const getPieces = () => pieces.value
|
const getPieces = () => pieces.value
|
||||||
const isLoading = () => loading.value
|
const isLoading = () => loading.value
|
||||||
|
|
||||||
|
const clearPiecesCache = () => {
|
||||||
|
pieces.value = []
|
||||||
|
total.value = 0
|
||||||
|
loaded.value = false
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pieces,
|
pieces,
|
||||||
total,
|
total,
|
||||||
loading,
|
loading,
|
||||||
|
loaded,
|
||||||
loadPieces,
|
loadPieces,
|
||||||
createPiece,
|
createPiece,
|
||||||
updatePiece: updatePieceData,
|
updatePiece: updatePieceData,
|
||||||
deletePiece,
|
deletePiece,
|
||||||
getPieces,
|
getPieces,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
clearPiecesCache,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
import { useState, useRuntimeConfig } from '#imports'
|
||||||
import type { Profile } from './useProfiles'
|
import type { Profile } from './useProfiles'
|
||||||
|
|
||||||
const buildUrl = (path: string): string => {
|
const buildUrl = (path: string): string => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const baseUrl = import.meta.server
|
const base = ((config.public.apiBaseUrl as string) || '').replace(/\/$/, '')
|
||||||
? ((config.apiBaseUrl as string) || (config.public.apiBaseUrl as string) || '')
|
|
||||||
: ((config.public.apiBaseUrl as string) || '')
|
|
||||||
const base = baseUrl.replace(/\/$/, '')
|
|
||||||
return `${base}${path}`
|
return `${base}${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,19 +12,12 @@ export function useProfileSession() {
|
|||||||
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
const sessionLoaded = useState<boolean>('profileSession:loaded', () => false)
|
||||||
const loading = useState<boolean>('profileSession:loading', () => false)
|
const loading = useState<boolean>('profileSession:loading', () => false)
|
||||||
|
|
||||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
|
||||||
if (!import.meta.server) { return undefined }
|
|
||||||
const headers = useRequestHeaders(['cookie'])
|
|
||||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
const fetchCurrentProfile = async (): Promise<Profile | null> => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
activeProfile.value = await $fetch<Profile>(buildUrl('/session/profile'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as { status?: number }
|
const err = error as { status?: number }
|
||||||
@@ -51,12 +41,15 @@ export function useProfileSession() {
|
|||||||
return Promise.resolve(activeProfile.value)
|
return Promise.resolve(activeProfile.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const activateProfile = async (profileId: string): Promise<void> => {
|
const activateProfile = async (profileId: string, password?: string): Promise<void> => {
|
||||||
|
const body: Record<string, string> = { profileId }
|
||||||
|
if (password) {
|
||||||
|
body.password = password
|
||||||
|
}
|
||||||
await $fetch(buildUrl('/session/profile'), {
|
await $fetch(buildUrl('/session/profile'), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: { profileId },
|
body,
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
await fetchCurrentProfile()
|
await fetchCurrentProfile()
|
||||||
}
|
}
|
||||||
@@ -66,7 +59,6 @@ export function useProfileSession() {
|
|||||||
await $fetch(buildUrl('/session/profile'), {
|
await $fetch(buildUrl('/session/profile'), {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
activeProfile.value = null
|
activeProfile.value = null
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useState, useRequestHeaders, useRuntimeConfig } from '#imports'
|
import { useState, useRuntimeConfig } from '#imports'
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
|
email?: string | null
|
||||||
|
isActive?: boolean
|
||||||
|
hasPassword?: boolean
|
||||||
|
roles?: string[]
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,19 +22,12 @@ export function useProfiles() {
|
|||||||
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
const loadingProfiles = useState<boolean>('profiles:loading', () => false)
|
||||||
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
const profilesLoaded = useState<boolean>('profiles:loaded', () => false)
|
||||||
|
|
||||||
const getSessionHeaders = (): Record<string, string> | undefined => {
|
|
||||||
if (!import.meta.server) { return undefined }
|
|
||||||
const headers = useRequestHeaders(['cookie'])
|
|
||||||
return headers?.cookie ? { cookie: headers.cookie } : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchProfiles = async (): Promise<Profile[]> => {
|
const fetchProfiles = async (): Promise<Profile[]> => {
|
||||||
loadingProfiles.value = true
|
loadingProfiles.value = true
|
||||||
try {
|
try {
|
||||||
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
profiles.value = await $fetch<Profile[]>(buildUrl('/session/profiles'), {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
})
|
||||||
profilesLoaded.value = true
|
profilesLoaded.value = true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -43,32 +40,10 @@ export function useProfiles() {
|
|||||||
return profiles.value
|
return profiles.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const createProfile = async ({ firstName, lastName }: { firstName: string; lastName: string }): Promise<Profile> => {
|
|
||||||
const profile = await $fetch<Profile>(buildUrl('/session/profiles'), {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
body: { firstName, lastName },
|
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
|
||||||
await fetchProfiles()
|
|
||||||
return profile
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteProfile = async (profileId: string): Promise<void> => {
|
|
||||||
await $fetch(buildUrl(`/session/profiles/${profileId}`), {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: getSessionHeaders(),
|
|
||||||
})
|
|
||||||
await fetchProfiles()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profiles,
|
profiles,
|
||||||
loadingProfiles,
|
loadingProfiles,
|
||||||
profilesLoaded,
|
profilesLoaded,
|
||||||
fetchProfiles,
|
fetchProfiles,
|
||||||
createProfile,
|
|
||||||
deleteProfile,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useProfileSession } from "#imports";
|
import { useProfileSession, usePermissions } from "#imports";
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
const { ensureSession, fetchCurrentProfile, activeProfile } =
|
const { ensureSession, activeProfile } = useProfileSession();
|
||||||
useProfileSession();
|
|
||||||
await ensureSession();
|
await ensureSession();
|
||||||
|
|
||||||
const rawPath = to?.path ?? "";
|
const rawPath = to?.path ?? "";
|
||||||
@@ -14,11 +13,21 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
fullPath.startsWith("/profiles") ||
|
fullPath.startsWith("/profiles") ||
|
||||||
routeName.startsWith("profiles");
|
routeName.startsWith("profiles");
|
||||||
|
|
||||||
if (process.client && !activeProfile.value) {
|
// Redirect to login if no active profile
|
||||||
await fetchCurrentProfile();
|
if (!activeProfile.value && !isProfilesRoute) {
|
||||||
}
|
|
||||||
|
|
||||||
if (process.client && !activeProfile.value && !isProfilesRoute) {
|
|
||||||
return navigateTo("/profiles");
|
return navigateTo("/profiles");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permission checks
|
||||||
|
if (activeProfile.value) {
|
||||||
|
const { isAdmin } = usePermissions();
|
||||||
|
|
||||||
|
// Admin-only routes
|
||||||
|
if (normalizedPath.startsWith("/admin")) {
|
||||||
|
if (!isAdmin.value) {
|
||||||
|
return navigateTo("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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>
|
||||||
245
app/pages/admin/index.vue
Normal file
245
app/pages/admin/index.vue
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container mx-auto p-6 max-w-6xl">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
Administration des profils
|
||||||
|
</h1>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
|
||||||
|
Nouveau profil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<span class="loading loading-spinner loading-lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="profiles.length" class="overflow-x-auto">
|
||||||
|
<table class="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Mot de passe</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="profile in profiles" :key="profile.id">
|
||||||
|
<td class="font-medium">
|
||||||
|
{{ profile.firstName }} {{ profile.lastName }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm text-base-content/70">
|
||||||
|
{{ profile.email || '-' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
class="select select-bordered select-xs"
|
||||||
|
:value="primaryRole(profile)"
|
||||||
|
@change="handleRoleChange(profile.id, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="ROLE_ADMIN">
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">
|
||||||
|
Gestionnaire
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_VIEWER">
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="profile.hasPassword" class="badge badge-success badge-sm">Oui</span>
|
||||||
|
<span v-else class="badge badge-ghost badge-sm">Non</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs ml-1"
|
||||||
|
@click="openPasswordDialog(profile.id)"
|
||||||
|
>
|
||||||
|
{{ profile.hasPassword ? 'Changer' : 'Definir' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="profile.isActive ? 'badge-success' : 'badge-error'"
|
||||||
|
>
|
||||||
|
{{ profile.isActive ? 'Actif' : 'Inactif' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
v-if="profile.isActive"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
@click="handleDeactivate(profile.id)"
|
||||||
|
>
|
||||||
|
Desactiver
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-center py-12 text-base-content/60">
|
||||||
|
Aucun profil.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Profile Dialog -->
|
||||||
|
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Nouveau profil
|
||||||
|
</h3>
|
||||||
|
<form @submit.prevent="handleCreate">
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Prenom</span></label>
|
||||||
|
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
|
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Email</span></label>
|
||||||
|
<input v-model="createForm.email" type="email" class="input input-bordered">
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Mot de passe</span></label>
|
||||||
|
<input v-model="createForm.password" type="password" class="input input-bordered">
|
||||||
|
</div>
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Role</span></label>
|
||||||
|
<select v-model="createForm.role" class="select select-bordered">
|
||||||
|
<option value="ROLE_ADMIN">
|
||||||
|
Admin
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_GESTIONNAIRE">
|
||||||
|
Gestionnaire
|
||||||
|
</option>
|
||||||
|
<option value="ROLE_VIEWER">
|
||||||
|
Viewer
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="creating">
|
||||||
|
<span v-if="creating" class="loading loading-spinner loading-xs" />
|
||||||
|
Creer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="button" @click="showCreateDialog = false">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Set Password Dialog -->
|
||||||
|
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="font-bold text-lg mb-4">
|
||||||
|
Definir le mot de passe
|
||||||
|
</h3>
|
||||||
|
<form @submit.prevent="handleSetPassword">
|
||||||
|
<div class="form-control mb-3">
|
||||||
|
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
|
||||||
|
<input v-model="newPassword" type="password" class="input input-bordered" required>
|
||||||
|
</div>
|
||||||
|
<div class="modal-action">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
|
||||||
|
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
|
||||||
|
Valider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button type="button" @click="showPasswordDialog = false">
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useAdminProfiles } from '#imports'
|
||||||
|
|
||||||
|
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||||
|
|
||||||
|
const showCreateDialog = ref(false)
|
||||||
|
const showPasswordDialog = ref(false)
|
||||||
|
const creating = ref(false)
|
||||||
|
const settingPassword = ref(false)
|
||||||
|
const passwordProfileId = ref(null)
|
||||||
|
const newPassword = ref('')
|
||||||
|
|
||||||
|
const createForm = ref({
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 'ROLE_VIEWER',
|
||||||
|
})
|
||||||
|
|
||||||
|
const primaryRole = (profile) => {
|
||||||
|
const roles = profile.roles || []
|
||||||
|
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
|
||||||
|
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
|
||||||
|
return 'ROLE_VIEWER'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
creating.value = true
|
||||||
|
try {
|
||||||
|
const data = { ...createForm.value }
|
||||||
|
if (!data.email) { delete data.email }
|
||||||
|
if (!data.password) { delete data.password }
|
||||||
|
await createProfile(data)
|
||||||
|
showCreateDialog.value = false
|
||||||
|
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRoleChange = async (profileId, role) => {
|
||||||
|
await updateRole(profileId, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPasswordDialog = (profileId) => {
|
||||||
|
passwordProfileId.value = profileId
|
||||||
|
newPassword.value = ''
|
||||||
|
showPasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetPassword = async () => {
|
||||||
|
if (!passwordProfileId.value) { return }
|
||||||
|
settingPassword.value = true
|
||||||
|
try {
|
||||||
|
await setPassword(passwordProfileId.value, newPassword.value)
|
||||||
|
showPasswordDialog.value = false
|
||||||
|
} finally {
|
||||||
|
settingPassword.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeactivate = async (profileId) => {
|
||||||
|
await deactivateProfile(profileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAll()
|
||||||
|
})
|
||||||
|
</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>
|
||||||
331
app/pages/comments.vue
Normal file
331
app/pages/comments.vue
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">
|
||||||
|
Commentaires
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<!-- Filtres -->
|
||||||
|
<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="comment-status"
|
||||||
|
>
|
||||||
|
Statut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-status"
|
||||||
|
v-model="statusFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="open">
|
||||||
|
Ouverts
|
||||||
|
</option>
|
||||||
|
<option value="resolved">
|
||||||
|
Résolus
|
||||||
|
</option>
|
||||||
|
<option value="">
|
||||||
|
Tous
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-entity-type"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-entity-type"
|
||||||
|
v-model="entityTypeFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Tous
|
||||||
|
</option>
|
||||||
|
<option value="machine">
|
||||||
|
Machine
|
||||||
|
</option>
|
||||||
|
<option value="piece">
|
||||||
|
Pièce
|
||||||
|
</option>
|
||||||
|
<option value="composant">
|
||||||
|
Composant
|
||||||
|
</option>
|
||||||
|
<option value="product">
|
||||||
|
Produit
|
||||||
|
</option>
|
||||||
|
<option value="piece_category">
|
||||||
|
Catégorie pièce
|
||||||
|
</option>
|
||||||
|
<option value="component_category">
|
||||||
|
Catégorie composant
|
||||||
|
</option>
|
||||||
|
<option value="product_category">
|
||||||
|
Catégorie produit
|
||||||
|
</option>
|
||||||
|
<option value="machine_skeleton">
|
||||||
|
Squelette machine
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-per-page"
|
||||||
|
>
|
||||||
|
Par page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-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">
|
||||||
|
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loadingList" class="flex justify-center py-8">
|
||||||
|
<span class="loading loading-spinner" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty states -->
|
||||||
|
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
||||||
|
Aucun commentaire trouvé.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm md:table-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Contenu</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Auteur</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th v-if="canEdit">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="hover"
|
||||||
|
>
|
||||||
|
<td class="max-w-xs">
|
||||||
|
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
{{ entityTypeLabel(comment.entityType) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="getEntityRoute(comment)"
|
||||||
|
:to="getEntityRoute(comment)!"
|
||||||
|
class="link link-primary text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ comment.entityName || comment.entityId }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-sm">
|
||||||
|
{{ comment.entityName || comment.entityId }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ comment.authorName }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm whitespace-nowrap">
|
||||||
|
{{ formatCommentDate(comment.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||||
|
>
|
||||||
|
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="canEdit" @click.stop>
|
||||||
|
<button
|
||||||
|
v-if="comment.status === 'open'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-xs gap-1"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleResolve(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideCheck class="w-3 h-3" />
|
||||||
|
Résoudre
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-base-content/50">
|
||||||
|
{{ comment.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
@click="goToPage(page - 1)"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
<span class="flex items-center text-sm text-base-content/70">
|
||||||
|
Page {{ page }} / {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
@click="goToPage(page + 1)"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchAllComments,
|
||||||
|
resolveComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(20)
|
||||||
|
const statusFilter = ref('open')
|
||||||
|
const entityTypeFilter = ref('')
|
||||||
|
const loadingList = ref(false)
|
||||||
|
|
||||||
|
const totalPages = computed(() =>
|
||||||
|
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
machine: 'Machine',
|
||||||
|
piece: 'Pièce',
|
||||||
|
composant: 'Composant',
|
||||||
|
product: 'Produit',
|
||||||
|
piece_category: 'Cat. pièce',
|
||||||
|
component_category: 'Cat. composant',
|
||||||
|
product_category: 'Cat. produit',
|
||||||
|
machine_skeleton: 'Squelette',
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityTypeLabel = (type: string): string =>
|
||||||
|
ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loadingList.value = true
|
||||||
|
const result = await fetchAllComments({
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
entityType: entityTypeFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = result.data ?? []
|
||||||
|
total.value = result.total ?? 0
|
||||||
|
}
|
||||||
|
loadingList.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
page.value = 1
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToPage = (p: number) => {
|
||||||
|
page.value = p
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
|
||||||
|
machine: (id: string) => `/machine/${id}`,
|
||||||
|
piece: (id: string) => `/pieces/${id}/edit`,
|
||||||
|
composant: (id: string) => `/component/${id}/edit`,
|
||||||
|
product: (id: string) => `/product/${id}/edit`,
|
||||||
|
piece_category: (id: string) => `/piece-category/${id}/edit`,
|
||||||
|
component_category: (id: string) => `/component-category/${id}/edit`,
|
||||||
|
product_category: (id: string) => `/product-category/${id}/edit`,
|
||||||
|
machine_skeleton: (id: string) => `/type/${id}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntityRoute = (comment: Comment): string | null => {
|
||||||
|
const builder = ENTITY_ROUTE_MAP[comment.entityType]
|
||||||
|
return builder ? builder(comment.entityId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadComments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -130,7 +130,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||||
<td>{{ component.reference || '—' }}</td>
|
<td>{{ component.reference || '—' }}</td>
|
||||||
<td>{{ resolveComponentType(component) }}</td>
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="component.typeComposant?.id"
|
||||||
|
:to="`/component-category/${component.typeComposant.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolveComponentType(component) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolveComponentType(component) }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -140,6 +149,7 @@
|
|||||||
Modifier
|
Modifier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="loadingComposants"
|
:disabled="loadingComposants"
|
||||||
@@ -167,29 +177,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useComposants } from '~/composables/useComposants'
|
import { useComposants } from '~/composables/useComposants'
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||||
|
|
||||||
// Pagination state
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const currentPage = ref(1)
|
const {
|
||||||
const itemsPerPage = ref(30)
|
page: currentPage,
|
||||||
|
perPage: itemsPerPage,
|
||||||
|
q: searchTerm,
|
||||||
|
sort: sortField,
|
||||||
|
dir: sortDirection,
|
||||||
|
} = useUrlState({
|
||||||
|
page: { default: 1, type: 'number' },
|
||||||
|
perPage: { default: 20, type: 'number' },
|
||||||
|
q: { default: '', debounce: 300 },
|
||||||
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => fetchComposants(),
|
||||||
|
})
|
||||||
|
|
||||||
const composantsTotal = computed(() => total.value)
|
const composantsTotal = computed(() => total.value)
|
||||||
const composantsOnPage = computed(() => composants.value.length)
|
const composantsOnPage = computed(() => composants.value.length)
|
||||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
// Search state with debounce
|
// Search debounce for API calls
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
@@ -202,12 +226,6 @@ const debouncedSearch = () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort state
|
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
|
||||||
'component-catalog',
|
|
||||||
{ field: 'name', direction: 'asc' },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enrichir les composants avec les types de composants complets
|
// Enrichir les composants avec les types de composants complets
|
||||||
const composantsList = computed(() => {
|
const composantsList = computed(() => {
|
||||||
return (composants.value || []).map((composant) => {
|
return (composants.value || []).map((composant) => {
|
||||||
@@ -225,7 +243,8 @@ const fetchComposants = async () => {
|
|||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: sortField.value,
|
||||||
orderDir: sortDirection.value
|
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||||
|
force: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="component_category"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="initialData?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -128,6 +140,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/component-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="COMPONENT"
|
initial-category="COMPONENT"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de composant',
|
title: 'Nouvelle catégorie de composant',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
Mettez à jour les informations du composant et ses champs personnalisés.
|
Mettez à jour les informations du composant et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="component?.constructeurs || []"
|
:initial-options="component?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -286,14 +286,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -304,24 +304,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -329,7 +329,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,7 +347,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -419,6 +419,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments"
|
:disabled="uploadingDocuments"
|
||||||
@@ -511,6 +512,16 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="composant"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="component?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -566,6 +577,7 @@ interface ComponentCatalogType extends ModelType {
|
|||||||
customFields?: Array<Record<string, any>>
|
customFields?: Array<Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
@@ -576,7 +588,7 @@ const { updateComposant, loadComposants, composants: componentCatalogRef } = use
|
|||||||
const { pieces, loadPieces } = usePieces()
|
const { pieces, loadPieces } = usePieces()
|
||||||
const { products, loadProducts } = useProducts()
|
const { products, loadProducts } = useProducts()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const {
|
const {
|
||||||
@@ -746,6 +758,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
component.value &&
|
component.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
@@ -764,12 +777,10 @@ const fetchComponent = async () => {
|
|||||||
component.value = result.data
|
component.value = result.data
|
||||||
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
componentDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
|
|
||||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
component.value.customFieldValues = customValues.data
|
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
loadHistory(result.data.id).catch(() => {})
|
||||||
}
|
|
||||||
await loadHistory(result.data.id)
|
|
||||||
} else {
|
} else {
|
||||||
component.value = null
|
component.value = null
|
||||||
componentDocuments.value = []
|
componentDocuments.value = []
|
||||||
@@ -805,7 +816,9 @@ watch(
|
|||||||
void ensureConstructeurs(editionForm.constructeurIds)
|
void ensureConstructeurs(editionForm.constructeurIds)
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
|
// After setting selectedTypeId, read selectedTypeStructure.value (now updated) instead of
|
||||||
|
// the stale destructured currentStructure which was captured before the ID change.
|
||||||
|
refreshCustomFieldInputs(selectedTypeStructure.value ?? currentStructure, currentComponent.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
@@ -1130,14 +1143,15 @@ onMounted(async () => {
|
|||||||
loadComponentTypes(),
|
loadComponentTypes(),
|
||||||
loadPieceTypes(),
|
loadPieceTypes(),
|
||||||
loadProductTypes(),
|
loadProductTypes(),
|
||||||
loadPieces({ itemsPerPage: 500 }),
|
|
||||||
loadProducts({ itemsPerPage: 500, force: true }),
|
|
||||||
loadComposants({ itemsPerPage: 500 }),
|
|
||||||
fetchComponent(),
|
fetchComponent(),
|
||||||
])
|
])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (component.value?.id) {
|
|
||||||
await refreshDocuments()
|
// Defer bulk catalog loads — not needed for initial render
|
||||||
}
|
Promise.allSettled([
|
||||||
|
loadPieces({ itemsPerPage: 200 }),
|
||||||
|
loadProducts({ itemsPerPage: 200 }),
|
||||||
|
loadComposants({ itemsPerPage: 200 }),
|
||||||
|
]).catch(() => {})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
Sélectionnez la catégorie cible puis complétez les informations du composant.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/component-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -253,14 +253,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -271,24 +271,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -296,7 +296,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -401,6 +401,7 @@ const {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -755,6 +756,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
Gérez les fournisseurs et leurs coordonnées.
|
Gérez les fournisseurs et leurs coordonnées.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="openCreateModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||||
Nouveau fournisseur
|
Nouveau fournisseur
|
||||||
</button>
|
</button>
|
||||||
@@ -73,9 +73,9 @@
|
|||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
<button class="btn btn-ghost btn-xs" @click="openEditModal(constructeur)">
|
||||||
Modifier
|
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
<button v-if="canEdit" class="btn btn-error btn-xs" @click="confirmDelete(constructeur)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,22 +90,22 @@
|
|||||||
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="font-bold text-lg mb-4">
|
<h3 class="font-bold text-lg mb-4">
|
||||||
{{ editingConstructeur ? 'Modifier' : 'Nouveau' }} fournisseur
|
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
|
||||||
</h3>
|
</h3>
|
||||||
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
<form class="space-y-4" @submit.prevent="saveConstructeur">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
<label class="label"><span class="label-text">Nom</span></label>
|
||||||
<input v-model="form.name" type="text" class="input input-bordered" required>
|
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<FieldEmail v-model="form.email" label="Email" />
|
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
|
||||||
<FieldPhone v-model="form.phone" label="Téléphone" />
|
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="closeModal">
|
<button type="button" class="btn" @click="closeModal">
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
|
||||||
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
|
||||||
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
@@ -126,6 +126,7 @@ import { usePersistedValue } from '~/composables/usePersistedValue'
|
|||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|
||||||
@@ -194,8 +195,18 @@ const closeModal = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const saveConstructeur = async () => {
|
const saveConstructeur = async () => {
|
||||||
|
const trimmedName = form.value.name.trim()
|
||||||
|
const duplicate = constructeurs.value.find(
|
||||||
|
(c) => c.name.toLowerCase() === trimmedName.toLowerCase()
|
||||||
|
&& c.id !== editingConstructeur.value?.id,
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
showError(`Un fournisseur "${duplicate.name}" existe déjà.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
const payload = { ...form.value }
|
const payload = { ...form.value, name: trimmedName }
|
||||||
if (!payload.email) { delete payload.email }
|
if (!payload.email) { delete payload.email }
|
||||||
if (!payload.phone) { delete payload.phone }
|
if (!payload.phone) { delete payload.phone }
|
||||||
let result
|
let result
|
||||||
@@ -221,7 +232,7 @@ const confirmDelete = async (constructeur) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadConstructeurs()
|
onMounted(() => loadConstructeurs())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -132,6 +132,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
|
import { useApi } from '~/composables/useApi'
|
||||||
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
import { formatFrenchDate } from '~/utils/date'
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
@@ -139,14 +141,17 @@ import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
|||||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
import IconLucideFileSearch from '~icons/lucide/file-search'
|
||||||
|
|
||||||
const { documents, loading, loadDocuments } = useDocuments()
|
const { documents, loading, loadDocuments } = useDocuments()
|
||||||
|
const { get } = useApi()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const { q: searchTerm, filter: attachmentFilter } = useUrlState({
|
||||||
const attachmentFilter = ref('all')
|
q: { default: '', debounce: 300 },
|
||||||
|
filter: { default: 'all' },
|
||||||
|
})
|
||||||
const previewDocument = ref(null)
|
const previewDocument = ref(null)
|
||||||
const previewVisible = ref(false)
|
const previewVisible = ref(false)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDocuments()
|
loadDocuments({ itemsPerPage: 200 })
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredDocuments = computed(() => {
|
const filteredDocuments = computed(() => {
|
||||||
@@ -156,10 +161,10 @@ const filteredDocuments = computed(() => {
|
|||||||
return documents.value.filter((document) => {
|
return documents.value.filter((document) => {
|
||||||
const matchesFilter =
|
const matchesFilter =
|
||||||
filter === 'all' ||
|
filter === 'all' ||
|
||||||
(filter === 'site' && document.siteId) ||
|
(filter === 'site' && document.site) ||
|
||||||
(filter === 'machine' && document.machineId) ||
|
(filter === 'machine' && document.machine) ||
|
||||||
(filter === 'composant' && document.composantId) ||
|
(filter === 'composant' && document.composant) ||
|
||||||
(filter === 'piece' && document.pieceId)
|
(filter === 'piece' && document.piece)
|
||||||
|
|
||||||
if (!matchesFilter) { return false }
|
if (!matchesFilter) { return false }
|
||||||
|
|
||||||
@@ -192,22 +197,36 @@ const formatSize = (size) => {
|
|||||||
|
|
||||||
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
|
||||||
|
|
||||||
const downloadDocument = (doc) => {
|
/** Fetch the full document (with path) from the API on demand. */
|
||||||
if (!doc?.path) { return }
|
const fetchDocumentPath = async (doc) => {
|
||||||
|
if (doc?.path) { return doc.path }
|
||||||
|
if (!doc?.id) { return null }
|
||||||
|
const result = await get(`/documents/${doc.id}`)
|
||||||
|
if (result.success && result.data?.path) {
|
||||||
|
doc.path = result.data.path
|
||||||
|
return result.data.path
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (doc.path.startsWith('data:')) {
|
const downloadDocument = async (doc) => {
|
||||||
|
const path = await fetchDocumentPath(doc)
|
||||||
|
if (!path) { return }
|
||||||
|
|
||||||
|
if (path.startsWith('data:')) {
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = doc.path
|
link.href = path
|
||||||
link.download = doc.filename || doc.name || 'document'
|
link.download = doc.filename || doc.name || 'document'
|
||||||
link.click()
|
link.click()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
window.open(doc.path, '_blank')
|
window.open(path, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPreview = (doc) => {
|
const openPreview = async (doc) => {
|
||||||
if (!canPreviewDocument(doc)) { return }
|
if (!canPreviewDocument(doc)) { return }
|
||||||
|
await fetchDocumentPath(doc)
|
||||||
previewDocument.value = doc
|
previewDocument.value = doc
|
||||||
previewVisible.value = true
|
previewVisible.value = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,10 +104,11 @@
|
|||||||
Commencez par ajouter des sites et des machines.
|
Commencez par ajouter des sites et des machines.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
<button class="btn btn-primary" @click="showAddSiteModal = true">
|
<button v-if="canEdit" class="btn btn-primary" @click="showAddSiteModal = true">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@click="showAddMachineModal = true"
|
@click="showAddMachineModal = true"
|
||||||
>
|
>
|
||||||
@@ -239,12 +240,14 @@
|
|||||||
|
|
||||||
<div class="card-actions justify-end mt-3">
|
<div class="card-actions justify-end mt-3">
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-xs btn-outline"
|
class="btn btn-xs btn-outline"
|
||||||
@click.stop="editMachine(machine)"
|
@click.stop="editMachine(machine)"
|
||||||
>
|
>
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-xs btn-error"
|
class="btn btn-xs btn-error"
|
||||||
@click.stop="confirmDeleteMachine(machine)"
|
@click.stop="confirmDeleteMachine(machine)"
|
||||||
>
|
>
|
||||||
@@ -277,6 +280,7 @@
|
|||||||
Aucune machine dans ce site
|
Aucune machine dans ce site
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-sm btn-primary"
|
class="btn btn-sm btn-primary"
|
||||||
@click="addMachineToSite(site)"
|
@click="addMachineToSite(site)"
|
||||||
>
|
>
|
||||||
@@ -304,11 +308,12 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Usine de production"
|
placeholder="Ex: Usine de production"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SiteContactFormFields :form="newSite" />
|
<SiteContactFormFields :form="newSite" :disabled="!canEdit" />
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button
|
<button
|
||||||
@@ -318,7 +323,7 @@
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||||
Créer le site
|
Créer le site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,6 +348,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Presse hydraulique #1"
|
placeholder="Ex: Presse hydraulique #1"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +360,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="newMachine.siteId"
|
v-model="newMachine.siteId"
|
||||||
class="select select-bordered"
|
class="select select-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -374,6 +381,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="newMachine.typeMachineId"
|
v-model="newMachine.typeMachineId"
|
||||||
class="select select-bordered"
|
class="select select-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -398,6 +406,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: PRESS-001"
|
placeholder="Ex: PRESS-001"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,7 +455,7 @@
|
|||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary" :disabled="!canEdit">
|
||||||
Créer la machine
|
Créer la machine
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,6 +483,7 @@ import IconLucideTag from '~icons/lucide/tag'
|
|||||||
import { formatPhone } from '~/utils/formatters/phone'
|
import { formatPhone } from '~/utils/formatters/phone'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { sites, loading, loadSites, createSite } = useSites()
|
const { sites, loading, loadSites, createSite } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||||
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card-actions justify-end mt-4">
|
<div class="card-actions justify-end mt-4">
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
class="btn btn-sm btn-error"
|
class="btn btn-sm btn-error"
|
||||||
@click.stop="confirmDeleteType(type)"
|
@click.stop="confirmDeleteType(type)"
|
||||||
>
|
>
|
||||||
@@ -108,6 +109,7 @@ import IconLucidePackage from "~icons/lucide/package";
|
|||||||
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
import IconLucideLayoutGrid from "~icons/lucide/layout-grid";
|
||||||
import IconLucideBox from "~icons/lucide/box";
|
import IconLucideBox from "~icons/lucide/box";
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions();
|
||||||
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
const { machineTypes, loadMachineTypes, deleteMachineType } =
|
||||||
useMachineTypesApi();
|
useMachineTypesApi();
|
||||||
|
|
||||||
|
|||||||
@@ -19,15 +19,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TypeEditForm
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit }">
|
||||||
:key="formKey"
|
<TypeEditForm
|
||||||
v-model="draftType"
|
:key="formKey"
|
||||||
:saving="creating"
|
v-model="draftType"
|
||||||
:resettable="false"
|
:saving="!canEdit || creating"
|
||||||
submit-label="Créer le type"
|
:resettable="false"
|
||||||
submit-loading-label="Création..."
|
submit-label="Créer le type"
|
||||||
@submit="handleSubmit"
|
submit-loading-label="Création..."
|
||||||
/>
|
@submit="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,6 +96,7 @@ import IconLucideBox from '~icons/lucide/box'
|
|||||||
|
|
||||||
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes, createMachineType } = useMachineTypesApi()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const formKey = ref(0)
|
const formKey = ref(0)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
|
|||||||
@@ -108,6 +108,16 @@
|
|||||||
@edit-piece="d.editPiece"
|
@edit-piece="d.editPiece"
|
||||||
@custom-field-update="d.updatePieceCustomField"
|
@custom-field-update="d.updatePieceCustomField"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="machine"
|
||||||
|
:entity-id="String(machineId)"
|
||||||
|
:entity-name="d.machine.value?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -163,6 +173,7 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const machineId = route.params.id
|
const machineId = route.params.id
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
if (!machineId) {
|
if (!machineId) {
|
||||||
console.error('ID de machine manquant')
|
console.error('ID de machine manquant')
|
||||||
@@ -212,7 +223,7 @@ onMounted(() => {
|
|||||||
d.loadMachineData()
|
d.loadMachineData()
|
||||||
d.loadInitialData()
|
d.loadInitialData()
|
||||||
|
|
||||||
if (route.query.edit === 'true') {
|
if (route.query.edit === 'true' && canEdit.value) {
|
||||||
d.isEditMode.value = true
|
d.isEditMode.value = true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -118,7 +118,7 @@
|
|||||||
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
<button class="btn btn-sm btn-outline" @click.stop="editMachine(machine)">
|
||||||
Modifier
|
Modifier
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
<button v-if="canEdit" class="btn btn-sm btn-error" @click.stop="confirmDeleteMachine(machine)">
|
||||||
Supprimer
|
Supprimer
|
||||||
</button>
|
</button>
|
||||||
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-sm btn-primary">
|
||||||
@@ -144,6 +144,7 @@ import IconLucideMapPin from '~icons/lucide/map-pin'
|
|||||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
||||||
import IconLucideTag from '~icons/lucide/tag'
|
import IconLucideTag from '~icons/lucide/tag'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
const { machines, loading, loadMachines, deleteMachine } = useMachines()
|
||||||
const { sites, loadSites } = useSites()
|
const { sites, loadSites } = useSites()
|
||||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: Presse hydraulique #1"
|
placeholder="Ex: Presse hydraulique #1"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
<label class="label" for="machine-field-site">
|
<label class="label" for="machine-field-site">
|
||||||
<span class="label-text">Site</span>
|
<span class="label-text">Site</span>
|
||||||
</label>
|
</label>
|
||||||
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" required>
|
<select id="machine-field-site" v-model="c.newMachine.siteId" class="select select-bordered" :disabled="!canEdit" required>
|
||||||
<option value="">
|
<option value="">
|
||||||
Sélectionner un site
|
Sélectionner un site
|
||||||
</option>
|
</option>
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
v-model="c.newMachine.typeMachineId"
|
v-model="c.newMachine.typeMachineId"
|
||||||
:options="c.machineTypes"
|
:options="c.machineTypes"
|
||||||
:loading="c.machineTypesLoading"
|
:loading="c.machineTypesLoading"
|
||||||
|
:disabled="!canEdit"
|
||||||
placeholder="Rechercher un type…"
|
placeholder="Rechercher un type…"
|
||||||
empty-text="Aucun type trouvé"
|
empty-text="Aucun type trouvé"
|
||||||
:option-label="c.machineTypeLabel"
|
:option-label="c.machineTypeLabel"
|
||||||
@@ -74,6 +76,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="Ex: PRESS-001"
|
placeholder="Ex: PRESS-001"
|
||||||
class="input input-bordered"
|
class="input input-bordered"
|
||||||
|
:disabled="!canEdit"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,7 +174,7 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
:disabled="!c.canCreateMachine || c.submitting"
|
:disabled="!canEdit || !c.canCreateMachine || c.submitting"
|
||||||
:class="{ loading: c.submitting }"
|
:class="{ loading: c.submitting }"
|
||||||
>
|
>
|
||||||
Créer la machine
|
Créer la machine
|
||||||
@@ -194,4 +197,5 @@ import RequirementProductSelector from '~/components/machine/create/RequirementP
|
|||||||
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
|
import MachineCreatePreview from '~/components/machine/create/MachineCreatePreview.vue'
|
||||||
|
|
||||||
const c = proxyRefs(useMachineCreatePage())
|
const c = proxyRefs(useMachineCreatePage())
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="piece_category"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="initialData?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -126,6 +138,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/piece-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="PIECE"
|
initial-category="PIECE"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de pièce',
|
title: 'Nouvelle catégorie de pièce',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -152,7 +152,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ resolvePieceType(row.piece) }}</td>
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.piece.typePiece?.id"
|
||||||
|
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ resolvePieceType(row.piece) }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ resolvePieceType(row.piece) }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
@@ -162,6 +171,7 @@
|
|||||||
Modifier
|
Modifier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="loadingPieces"
|
:disabled="loadingPieces"
|
||||||
@@ -189,29 +199,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { usePieces } from '~/composables/usePieces'
|
import { usePieces } from '~/composables/usePieces'
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import Pagination from '~/components/common/Pagination.vue'
|
import Pagination from '~/components/common/Pagination.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||||
|
|
||||||
// Pagination state
|
// State synced with URL query params (preserved on back/forward navigation)
|
||||||
const currentPage = ref(1)
|
const {
|
||||||
const itemsPerPage = ref(30)
|
page: currentPage,
|
||||||
|
perPage: itemsPerPage,
|
||||||
|
q: searchTerm,
|
||||||
|
sort: sortField,
|
||||||
|
dir: sortDirection,
|
||||||
|
} = useUrlState({
|
||||||
|
page: { default: 1, type: 'number' },
|
||||||
|
perPage: { default: 20, type: 'number' },
|
||||||
|
q: { default: '', debounce: 300 },
|
||||||
|
sort: { default: 'name' },
|
||||||
|
dir: { default: 'asc' },
|
||||||
|
}, {
|
||||||
|
onRestore: () => fetchPieces(),
|
||||||
|
})
|
||||||
|
|
||||||
const piecesTotal = computed(() => total.value)
|
const piecesTotal = computed(() => total.value)
|
||||||
const piecesOnPage = computed(() => pieces.value.length)
|
const piecesOnPage = computed(() => pieces.value.length)
|
||||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||||
|
|
||||||
// Search state with debounce
|
// Search debounce for API calls
|
||||||
const searchTerm = ref('')
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const debouncedSearch = () => {
|
const debouncedSearch = () => {
|
||||||
@@ -224,12 +248,6 @@ const debouncedSearch = () => {
|
|||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort state
|
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
|
||||||
'pieces-catalog',
|
|
||||||
{ field: 'name', direction: 'asc' },
|
|
||||||
)
|
|
||||||
|
|
||||||
// Enrichir les pièces avec les types de pièces complets
|
// Enrichir les pièces avec les types de pièces complets
|
||||||
const piecesList = computed(() => {
|
const piecesList = computed(() => {
|
||||||
return (pieces.value || []).map((piece) => {
|
return (pieces.value || []).map((piece) => {
|
||||||
@@ -247,7 +265,8 @@ const fetchPieces = async () => {
|
|||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
itemsPerPage: itemsPerPage.value,
|
itemsPerPage: itemsPerPage.value,
|
||||||
orderBy: sortField.value,
|
orderBy: sortField.value,
|
||||||
orderDir: sortDirection.value
|
orderDir: sortDirection.value as 'asc' | 'desc',
|
||||||
|
force: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
Ajustez les informations de la pièce et ses champs personnalisés.
|
Ajustez les informations de la pièce et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="piece?.constructeurs || []"
|
:initial-options="piece?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit valide est requis pour cette pièce."
|
helper-text="Un produit valide est requis pour cette pièce."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -233,14 +233,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -251,24 +251,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -276,7 +276,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -294,7 +294,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -366,6 +366,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments"
|
:disabled="uploadingDocuments"
|
||||||
@@ -458,6 +459,16 @@
|
|||||||
Enregistrer les modifications
|
Enregistrer les modifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="piece"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="piece?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -511,12 +522,13 @@ interface PieceCatalogType extends ModelType {
|
|||||||
customFields?: Array<Record<string, any>>
|
customFields?: Array<Record<string, any>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { get } = useApi()
|
const { get } = useApi()
|
||||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||||
const { updatePiece } = usePieces()
|
const { updatePiece } = usePieces()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
@@ -731,6 +743,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
canEdit.value &&
|
||||||
piece.value &&
|
piece.value &&
|
||||||
editionForm.name &&
|
editionForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
@@ -750,20 +763,23 @@ const fetchPiece = async () => {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
piece.value = result.data
|
piece.value = result.data
|
||||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||||
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
|
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||||
piece.value.customFieldValues = customValues.data
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
}
|
|
||||||
await loadPieceTypeDetails(result.data)
|
// Use cached type from loadPieceTypes() instead of separate getModelType() call
|
||||||
await loadHistory(result.data.id)
|
loadPieceTypeDetailsFromCache(result.data)
|
||||||
|
|
||||||
|
// History is non-blocking — template handles its own loading state
|
||||||
|
loadHistory(result.data.id).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
pieceDocuments.value = []
|
pieceDocuments.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPieceTypeDetails = async (currentPiece: any) => {
|
const loadPieceTypeDetailsFromCache = (currentPiece: any) => {
|
||||||
const typeId = currentPiece?.typePieceId
|
const typeId = currentPiece?.typePieceId
|
||||||
|| extractRelationId(currentPiece?.typePiece)
|
|| extractRelationId(currentPiece?.typePiece)
|
||||||
|| ''
|
|| ''
|
||||||
@@ -771,15 +787,22 @@ const loadPieceTypeDetails = async (currentPiece: any) => {
|
|||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
// Look up in the already-loaded pieceTypes cache (from loadPieceTypes in onMounted)
|
||||||
const type = await getModelType(typeId)
|
const cachedType = (pieceTypes.value || []).find((t: any) => t.id === typeId) ?? null
|
||||||
|
if (cachedType) {
|
||||||
|
pieceTypeDetails.value = cachedType
|
||||||
|
refreshCustomFieldInputs((cachedType.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Fallback: fetch if not in cache (edge case)
|
||||||
|
getModelType(typeId).then((type) => {
|
||||||
if (type && typeof type === 'object') {
|
if (type && typeof type === 'object') {
|
||||||
pieceTypeDetails.value = type
|
pieceTypeDetails.value = type
|
||||||
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
refreshCustomFieldInputs((type.structure as PieceModelStructure | null) ?? null, currentPiece?.customFieldValues ?? null)
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
}).catch(() => {
|
||||||
pieceTypeDetails.value = null
|
pieceTypeDetails.value = null
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false
|
||||||
@@ -827,7 +850,10 @@ watch(
|
|||||||
pendingProductIds = []
|
pendingProductIds = []
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
|
// After setting selectedTypeId, read selectedType.value (now updated) instead of
|
||||||
|
// the stale destructured currentType which was captured before the ID change.
|
||||||
|
const resolvedType = selectedType.value ?? pieceTypeDetails.value ?? null
|
||||||
|
refreshCustomFieldInputs(resolvedType?.structure ?? null, currentPiece.customFieldValues)
|
||||||
|
|
||||||
initialized = true
|
initialized = true
|
||||||
},
|
},
|
||||||
@@ -838,9 +864,7 @@ watch(selectedType, (currentType) => {
|
|||||||
if (!piece.value || !currentType) {
|
if (!piece.value || !currentType) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!pieceTypeDetails.value) {
|
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(resolvedStructure, (currentStructure) => {
|
watch(resolvedStructure, (currentStructure) => {
|
||||||
@@ -920,8 +944,5 @@ const submitEdition = async () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
await Promise.allSettled([loadPieceTypes(), fetchPiece()])
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (piece.value?.id) {
|
|
||||||
await refreshDocuments()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<ProductSelect
|
<ProductSelect
|
||||||
:model-value="productSelections[entry.index] || null"
|
:model-value="productSelections[entry.index] || null"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
:type-product-id="entry.typeProductId"
|
:type-product-id="entry.typeProductId"
|
||||||
helper-text="Un produit est requis pour cette pièce."
|
helper-text="Un produit est requis pour cette pièce."
|
||||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -205,14 +205,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -223,24 +223,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -329,6 +329,7 @@ const { createPiece } = usePieces()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -478,6 +479,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name &&
|
creationForm.name &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -110,7 +110,16 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="font-medium">{{ row.product.name }}</td>
|
<td class="font-medium">{{ row.product.name }}</td>
|
||||||
<td>{{ row.product.reference || '—' }}</td>
|
<td>{{ row.product.reference || '—' }}</td>
|
||||||
<td>{{ row.product.typeProduct?.name || '—' }}</td>
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="row.product.typeProduct?.id"
|
||||||
|
:to="`/product-category/${row.product.typeProduct.id}/edit`"
|
||||||
|
class="link link-hover link-primary"
|
||||||
|
>
|
||||||
|
{{ row.product.typeProduct.name }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div
|
<div
|
||||||
v-if="row.suppliers.visible.length"
|
v-if="row.suppliers.visible.length"
|
||||||
@@ -144,6 +153,7 @@
|
|||||||
Modifier
|
Modifier
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
@click="confirmDelete(row.product)"
|
@click="confirmDelete(row.product)"
|
||||||
@@ -161,15 +171,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { useHead } from '#imports'
|
import { useHead } from '#imports'
|
||||||
import { useProducts } from '~/composables/useProducts'
|
import { useProducts } from '~/composables/useProducts'
|
||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
import { useUrlState } from '~/composables/useUrlState'
|
||||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Catalogue des produits',
|
title: 'Catalogue des produits',
|
||||||
}))
|
}))
|
||||||
@@ -186,11 +198,11 @@ const {
|
|||||||
const { productTypes, loadProductTypes } = useProductTypes()
|
const { productTypes, loadProductTypes } = useProductTypes()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const searchTerm = ref('')
|
const { q: searchTerm, sort: sortField, dir: sortDirection } = useUrlState({
|
||||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
q: { default: '', debounce: 300 },
|
||||||
'product-catalog',
|
sort: { default: 'name' },
|
||||||
{ field: 'name', direction: 'asc' },
|
dir: { default: 'asc' },
|
||||||
)
|
})
|
||||||
|
|
||||||
// Enrichir les produits avec les types de produits complets
|
// Enrichir les produits avec les types de produits complets
|
||||||
const normalizedProducts = computed(() => {
|
const normalizedProducts = computed(() => {
|
||||||
@@ -379,7 +391,7 @@ const resolvePreviewAlt = (product: Record<string, any>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const reload = async () => {
|
const reload = async () => {
|
||||||
await loadProducts({ force: true })
|
await loadProducts({ itemsPerPage: 200, force: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
const { confirm } = useConfirm()
|
||||||
@@ -400,7 +412,7 @@ const confirmDelete = async (product: Record<string, any>) => {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadProducts(),
|
loadProducts({ itemsPerPage: 200, force: true }),
|
||||||
loadProductTypes()
|
loadProductTypes()
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
:disable-submit="isSubmitBlocked"
|
:disable-submit="isSubmitBlocked"
|
||||||
:disable-submit-message="submitBlockMessage"
|
:disable-submit-message="submitBlockMessage"
|
||||||
:restricted-mode="isRestrictedMode"
|
:restricted-mode="isRestrictedMode"
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="product_category"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="initialData?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,7 @@ import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
|||||||
import { useProductTypes } from '~/composables/useProductTypes'
|
import { useProductTypes } from '~/composables/useProductTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { showError, showSuccess } = useToast()
|
const { showError, showSuccess } = useToast()
|
||||||
@@ -126,6 +138,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
if (guardSubmitOrNotify()) {
|
if (guardSubmitOrNotify()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink class="btn btn-ghost" to="/product-category">
|
<button type="button" class="btn btn-ghost" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
initial-category="PRODUCT"
|
initial-category="PRODUCT"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:readonly="!canEdit"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -35,6 +36,8 @@ import { createModelType } from '~/services/modelTypes'
|
|||||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
useHead(() => ({
|
useHead(() => ({
|
||||||
title: 'Nouvelle catégorie de produit',
|
title: 'Nouvelle catégorie de produit',
|
||||||
}))
|
}))
|
||||||
@@ -50,6 +53,7 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
|
||||||
|
if (!canEdit.value) return
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
const enrichedPayload = {
|
const enrichedPayload = {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-primary mt-6">
|
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
|
||||||
@@ -33,9 +33,9 @@
|
|||||||
Mettez à jour les informations du produit et ses champs personnalisés.
|
Mettez à jour les informations du produit et ses champs personnalisés.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
v-model="editionForm.name"
|
v-model="editionForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
v-model="editionForm.reference"
|
v-model="editionForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="editionForm.constructeurIds"
|
v-model="editionForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
:initial-options="product?.constructeurs || []"
|
:initial-options="product?.constructeurs || []"
|
||||||
/>
|
/>
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,7 +148,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -157,14 +157,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -175,24 +175,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -200,7 +200,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="saving"
|
:disabled="!canEdit || saving"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': saving || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedFiles"
|
v-model="selectedFiles"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -286,6 +286,7 @@
|
|||||||
Télécharger
|
Télécharger
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="canEdit"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs"
|
class="btn btn-error btn-xs"
|
||||||
:disabled="uploadingDocuments || saving"
|
:disabled="uploadingDocuments || saving"
|
||||||
@@ -381,6 +382,16 @@
|
|||||||
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<!-- Comments -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<CommentSection
|
||||||
|
entity-type="product"
|
||||||
|
:entity-id="String(route.params.id)"
|
||||||
|
:entity-name="product?.name"
|
||||||
|
show-resolved
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
@@ -424,11 +435,12 @@ import {
|
|||||||
historyDiffEntries as _historyDiffEntries,
|
historyDiffEntries as _historyDiffEntries,
|
||||||
} from '~/shared/utils/historyDisplayUtils'
|
} from '~/shared/utils/historyDisplayUtils'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { getProduct, updateProduct } = useProducts()
|
const { getProduct, updateProduct } = useProducts()
|
||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||||
const {
|
const {
|
||||||
loadDocumentsByProduct,
|
loadDocumentsByProduct,
|
||||||
uploadDocuments: uploadProductDocuments,
|
uploadDocuments: uploadProductDocuments,
|
||||||
@@ -489,7 +501,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() =>
|
const canSubmit = computed(() =>
|
||||||
Boolean(product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
|
||||||
@@ -520,15 +532,17 @@ const loadProduct = async () => {
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
product.value = result.data
|
product.value = result.data
|
||||||
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
|
||||||
|
|
||||||
await loadProductType()
|
await loadProductType()
|
||||||
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
|
||||||
if (customValues.success && Array.isArray(customValues.data)) {
|
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
|
||||||
product.value.customFieldValues = customValues.data
|
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues)
|
||||||
}
|
|
||||||
await hydrateForm()
|
hydrateForm()
|
||||||
await refreshDocuments()
|
|
||||||
await loadHistory(result.data.id)
|
// History is non-blocking — template handles its own loading state
|
||||||
|
loadHistory(result.data.id).catch(() => {})
|
||||||
} else {
|
} else {
|
||||||
product.value = null
|
product.value = null
|
||||||
}
|
}
|
||||||
@@ -587,9 +601,20 @@ const handleFilesAdded = async (files: File[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loadProductType = async () => {
|
const loadProductType = async () => {
|
||||||
|
// Try using the expanded typeProduct from entity response first
|
||||||
|
const embedded = product.value?.typeProduct
|
||||||
|
if (embedded && typeof embedded === 'object' && embedded.id) {
|
||||||
|
const embeddedStructure = embedded.structure ?? embedded.productSkeleton ?? null
|
||||||
|
if (embeddedStructure) {
|
||||||
|
productType.value = embedded
|
||||||
|
structure.value = normalizeProductStructureForSave(embeddedStructure)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!product.value?.typeProductId) {
|
if (!product.value?.typeProductId) {
|
||||||
productType.value = product.value?.typeProduct ?? null
|
productType.value = embedded ?? null
|
||||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -598,12 +623,12 @@ const loadProductType = async () => {
|
|||||||
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
structure.value = normalizeProductStructureForSave(type?.structure ?? type?.productSkeleton ?? null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement du type de produit:', error)
|
console.error('Erreur lors du chargement du type de produit:', error)
|
||||||
productType.value = product.value?.typeProduct ?? null
|
productType.value = embedded ?? null
|
||||||
structure.value = normalizeProductStructureForSave(productType.value?.structure ?? null)
|
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hydrateForm = async () => {
|
const hydrateForm = () => {
|
||||||
if (!product.value) {
|
if (!product.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -618,7 +643,8 @@ const hydrateForm = async () => {
|
|||||||
: ''
|
: ''
|
||||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||||
if (editionForm.constructeurIds.length) {
|
if (editionForm.constructeurIds.length) {
|
||||||
await ensureConstructeurs(editionForm.constructeurIds)
|
// Smart-cached + deduped — fire-and-forget, ConstructeurSelect handles its own loading
|
||||||
|
ensureConstructeurs(editionForm.constructeurIds).catch(() => {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/product-catalog" class="btn btn-ghost btn-sm md:btn-md self-start">
|
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
|
||||||
Retour au catalogue
|
Retour au catalogue
|
||||||
</NuxtLink>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
empty-text="Aucune catégorie disponible"
|
empty-text="Aucune catégorie disponible"
|
||||||
:option-label="typeOptionLabel"
|
:option-label="typeOptionLabel"
|
||||||
:option-description="typeOptionDescription"
|
:option-description="typeOptionDescription"
|
||||||
:disabled="loadingTypes || submitting"
|
:disabled="!canEdit || loadingTypes || submitting"
|
||||||
/>
|
/>
|
||||||
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
|
||||||
Chargement des catégories…
|
Chargement des catégories…
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
v-model="creationForm.name"
|
v-model="creationForm.name"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Nom affiché dans le catalogue"
|
placeholder="Nom affiché dans le catalogue"
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
v-model="creationForm.reference"
|
v-model="creationForm.reference"
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Référence interne ou fournisseur"
|
placeholder="Référence interne ou fournisseur"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
<ConstructeurSelect
|
<ConstructeurSelect
|
||||||
v-model="creationForm.constructeurIds"
|
v-model="creationForm.constructeurIds"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:disabled="submitting || !selectedType"
|
:disabled="!canEdit || submitting || !selectedType"
|
||||||
placeholder="Valeur indicatrice"
|
placeholder="Valeur indicatrice"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'number'"
|
v-else-if="field.type === 'number'"
|
||||||
@@ -144,14 +144,14 @@
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
v-else-if="field.type === 'select'"
|
v-else-if="field.type === 'select'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
class="select select-bordered select-sm md:select-md"
|
class="select select-bordered select-sm md:select-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<option value="">Sélectionner...</option>
|
<option value="">Sélectionner...</option>
|
||||||
<option
|
<option
|
||||||
@@ -162,24 +162,24 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<div v-else-if="field.type === 'boolean'" class="flex items-center gap-2">
|
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="checkbox checkbox-sm"
|
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||||
true-value="true"
|
true-value="true"
|
||||||
false-value="false"
|
false-value="false"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||||
</div>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-else-if="field.type === 'date'"
|
v-else-if="field.type === 'date'"
|
||||||
v-model="field.value"
|
v-model="field.value"
|
||||||
type="date"
|
type="date"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-else
|
v-else
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm md:input-md"
|
class="input input-bordered input-sm md:input-md"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:disabled="submitting"
|
:disabled="!canEdit || submitting"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div :class="{ 'pointer-events-none opacity-60': submitting || uploadingDocuments }">
|
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
|
||||||
<DocumentUpload
|
<DocumentUpload
|
||||||
v-model="selectedDocuments"
|
v-model="selectedDocuments"
|
||||||
title="Déposer vos fichiers"
|
title="Déposer vos fichiers"
|
||||||
@@ -267,6 +267,7 @@ const { createProduct } = useProducts()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { upsertCustomFieldValue } = useCustomFields()
|
const { upsertCustomFieldValue } = useCustomFields()
|
||||||
const { uploadDocuments } = useDocuments()
|
const { uploadDocuments } = useDocuments()
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
|
||||||
const selectedTypeId = ref<string>(initialTypeId.value)
|
const selectedTypeId = ref<string>(initialTypeId.value)
|
||||||
@@ -346,6 +347,7 @@ const requiredCustomFieldsFilled = computed(() =>
|
|||||||
)
|
)
|
||||||
|
|
||||||
const canSubmit = computed(() => Boolean(
|
const canSubmit = computed(() => Boolean(
|
||||||
|
canEdit.value &&
|
||||||
selectedType.value &&
|
selectedType.value &&
|
||||||
creationForm.name.trim().length >= 2 &&
|
creationForm.name.trim().length >= 2 &&
|
||||||
requiredCustomFieldsFilled.value &&
|
requiredCustomFieldsFilled.value &&
|
||||||
|
|||||||
@@ -1,55 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||||
<div class="w-full max-w-2xl">
|
<div class="w-full max-w-md">
|
||||||
<div class="card bg-base-100 shadow-2xl">
|
<div class="card bg-base-100 shadow-2xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h1 class="text-2xl font-bold mb-2">
|
<h1 class="text-2xl font-bold mb-6 text-center">
|
||||||
Choisir un profil
|
Connexion
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-base-content/70 mb-6">
|
|
||||||
Sélectionnez votre profil pour accéder à l'application. La création et la gestion se font via le menu utilisateur.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
<div v-if="loadingProfiles" class="flex justify-center py-8">
|
||||||
<header class="flex items-center justify-between">
|
<span class="loading loading-spinner loading-lg" />
|
||||||
<h2 class="font-semibold">
|
</div>
|
||||||
Profils disponibles
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost btn-xs"
|
|
||||||
:disabled="loadingProfiles"
|
|
||||||
@click="refreshProfiles"
|
|
||||||
>
|
|
||||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Rafraîchir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="profiles.length" class="space-y-2 max-h-64 overflow-y-auto">
|
<form v-else @submit.prevent="handleLogin">
|
||||||
<button
|
<div class="form-control mb-4">
|
||||||
v-for="profile in profiles"
|
<label class="label">
|
||||||
:key="profile.id"
|
<span class="label-text">Profil</span>
|
||||||
type="button"
|
</label>
|
||||||
class="btn btn-outline btn-sm w-full justify-between"
|
<select
|
||||||
@click="selectProfile(profile.id)"
|
v-model="selectedProfileId"
|
||||||
|
class="select select-bordered w-full"
|
||||||
|
required
|
||||||
>
|
>
|
||||||
<span>{{ profile.firstName }} {{ profile.lastName }}</span>
|
<option value="" disabled>
|
||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
Choisir un profil...
|
||||||
</button>
|
</option>
|
||||||
|
<option
|
||||||
|
v-for="profile in profiles"
|
||||||
|
:key="profile.id"
|
||||||
|
:value="profile.id"
|
||||||
|
>
|
||||||
|
{{ profile.firstName }} {{ profile.lastName }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-sm text-base-content/60">
|
|
||||||
Aucun profil enregistré.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer v-if="activeProfile" class="mt-6 flex justify-between items-center">
|
<div class="form-control mb-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Mot de passe</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="passwordInput"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Mot de passe"
|
||||||
|
class="input input-bordered w-full"
|
||||||
|
:class="{ 'input-error': loginError }"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loginError" class="text-error text-sm mb-4">
|
||||||
|
{{ loginError }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary w-full mt-4"
|
||||||
|
:disabled="!selectedProfileId || submitting"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer v-if="activeProfile" class="mt-6 pt-4 border-t border-base-300 flex justify-between items-center">
|
||||||
<div class="text-sm text-base-content/70">
|
<div class="text-sm text-base-content/70">
|
||||||
Profil actuel :
|
Connecte :
|
||||||
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
|
||||||
Déconnexion
|
Deconnexion
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,32 +78,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useProfiles, useProfileSession } from '#imports'
|
import { useProfiles, useProfileSession } from '#imports'
|
||||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
|
||||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
||||||
|
|
||||||
const refreshProfiles = async () => {
|
const selectedProfileId = ref('')
|
||||||
await fetchProfiles()
|
const password = ref('')
|
||||||
}
|
const loginError = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const passwordInput = ref(null)
|
||||||
|
|
||||||
const selectProfile = async (profileId) => {
|
const handleLogin = async () => {
|
||||||
|
if (!selectedProfileId.value) { return }
|
||||||
|
submitting.value = true
|
||||||
|
loginError.value = ''
|
||||||
try {
|
try {
|
||||||
await activateProfile(profileId)
|
await activateProfile(selectedProfileId.value, password.value || undefined)
|
||||||
await fetchProfiles()
|
|
||||||
await router.push('/')
|
await router.push('/')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la sélection du profil', error)
|
const err = error
|
||||||
|
if (err?.status === 401 || err?.statusCode === 401) {
|
||||||
|
loginError.value = 'Mot de passe incorrect.'
|
||||||
|
} else {
|
||||||
|
loginError.value = 'Erreur lors de la connexion.'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout()
|
await logout()
|
||||||
await router.push('/profiles')
|
selectedProfileId.value = ''
|
||||||
|
password.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl font-bold">
|
|
||||||
Gestion des profils
|
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Sélectionnez, créez ou supprimez des profils.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NuxtLink to="/" class="btn btn-ghost btn-sm">
|
|
||||||
Retour
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<section class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<header class="flex items-center justify-between">
|
|
||||||
<h2 class="card-title text-lg">
|
|
||||||
Profils existants
|
|
||||||
</h2>
|
|
||||||
<button type="button" class="btn btn-ghost btn-xs" :disabled="loadingProfiles" @click="refresh">
|
|
||||||
<span v-if="loadingProfiles" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Rafraîchir</span>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div v-if="profiles.length" class="space-y-2 max-h-80 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="profile in profiles"
|
|
||||||
:key="profile.id"
|
|
||||||
class="flex items-center justify-between rounded-lg border border-base-200 bg-base-100 px-3 py-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="font-medium">
|
|
||||||
{{ profile.firstName }} {{ profile.lastName }}
|
|
||||||
</p>
|
|
||||||
<p class="text-xs text-base-content/60">
|
|
||||||
ID : {{ profile.id }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="profile.id === activeProfile?.id ? 'btn-primary' : 'btn-outline'"
|
|
||||||
@click="select(profile.id)"
|
|
||||||
>
|
|
||||||
{{ profile.id === activeProfile?.id ? 'Actif' : 'Activer' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-error btn-sm"
|
|
||||||
@click="remove(profile.id)"
|
|
||||||
>
|
|
||||||
<span v-if="deleting === profile.id" class="loading loading-spinner loading-xs" />
|
|
||||||
<span v-else>Supprimer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p v-else class="text-sm text-base-content/60">
|
|
||||||
Aucun profil enregistré.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="card bg-base-100 shadow-lg">
|
|
||||||
<div class="card-body space-y-4">
|
|
||||||
<h2 class="card-title text-lg">
|
|
||||||
Créer un profil
|
|
||||||
</h2>
|
|
||||||
<form class="space-y-3" @submit.prevent="create">
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Prénom</span></label>
|
|
||||||
<input
|
|
||||||
v-model="createForm.firstName"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
placeholder="Prénom"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label"><span class="label-text">Nom</span></label>
|
|
||||||
<input
|
|
||||||
v-model="createForm.lastName"
|
|
||||||
type="text"
|
|
||||||
class="input input-bordered"
|
|
||||||
placeholder="Nom"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary w-full" :disabled="creating">
|
|
||||||
<span v-if="creating" class="loading loading-spinner loading-sm" />
|
|
||||||
<span v-else>Créer et activer</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeProfile" class="mt-8 flex items-center justify-between bg-base-100 shadow-lg rounded-lg p-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-base-content/70">
|
|
||||||
Profil actif :
|
|
||||||
</p>
|
|
||||||
<p class="font-semibold text-base-content">
|
|
||||||
{{ activeProfile.firstName }} {{ activeProfile.lastName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-outline" @click="handleLogout">
|
|
||||||
Déconnexion
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useProfiles, useProfileSession } from '#imports'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const { profiles, loadingProfiles, fetchProfiles, createProfile, deleteProfile } = useProfiles()
|
|
||||||
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
|
|
||||||
|
|
||||||
const createForm = reactive({
|
|
||||||
firstName: '',
|
|
||||||
lastName: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const creating = ref(false)
|
|
||||||
const deleting = ref(null)
|
|
||||||
|
|
||||||
const refresh = async () => {
|
|
||||||
await fetchProfiles()
|
|
||||||
await fetchCurrentProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = async (profileId) => {
|
|
||||||
try {
|
|
||||||
await activateProfile(profileId)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la sélection du profil', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const create = async () => {
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const profile = await createProfile({
|
|
||||||
firstName: createForm.firstName,
|
|
||||||
lastName: createForm.lastName
|
|
||||||
})
|
|
||||||
createForm.firstName = ''
|
|
||||||
createForm.lastName = ''
|
|
||||||
await activateProfile(profile.id)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la création du profil', error)
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { confirm } = useConfirm()
|
|
||||||
|
|
||||||
const remove = async (profileId) => {
|
|
||||||
if (!await confirm({ message: 'Supprimer ce profil ?' })) { return }
|
|
||||||
deleting.value = profileId
|
|
||||||
try {
|
|
||||||
await deleteProfile(profileId)
|
|
||||||
await refresh()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Erreur lors de la suppression du profil', error)
|
|
||||||
} finally {
|
|
||||||
deleting.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
|
||||||
await logout()
|
|
||||||
await refresh()
|
|
||||||
await router.push('/profiles')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await refresh()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<h2 class="text-2xl font-bold">
|
<h2 class="text-2xl font-bold">
|
||||||
Sites
|
Sites
|
||||||
</h2>
|
</h2>
|
||||||
<button class="btn btn-primary" @click="openCreateSiteModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
|
||||||
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
<p class="text-gray-500 mb-4">
|
<p class="text-gray-500 mb-4">
|
||||||
Commencez par ajouter votre premier site.
|
Commencez par ajouter votre premier site.
|
||||||
</p>
|
</p>
|
||||||
<button class="btn btn-primary" @click="openCreateSiteModal">
|
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
|
||||||
Ajouter un site
|
Ajouter un site
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,6 +50,7 @@
|
|||||||
<SiteCreateModal
|
<SiteCreateModal
|
||||||
:visible="showAddSiteModal"
|
:visible="showAddSiteModal"
|
||||||
:site="newSite"
|
:site="newSite"
|
||||||
|
:disabled="!canEdit"
|
||||||
@close="closeCreateModal"
|
@close="closeCreateModal"
|
||||||
@submit="handleCreateSite"
|
@submit="handleCreateSite"
|
||||||
/>
|
/>
|
||||||
@@ -64,6 +65,7 @@
|
|||||||
:can-preview-document="canPreviewDocument"
|
:can-preview-document="canPreviewDocument"
|
||||||
:document-icon="documentIcon"
|
:document-icon="documentIcon"
|
||||||
:format-size="formatSize"
|
:format-size="formatSize"
|
||||||
|
:disabled="!canEdit"
|
||||||
@close="closeEditModal"
|
@close="closeEditModal"
|
||||||
@submit="handleUpdateSite"
|
@submit="handleUpdateSite"
|
||||||
@remove-document="handleRemoveSiteDocument"
|
@remove-document="handleRemoveSiteDocument"
|
||||||
@@ -83,6 +85,8 @@ import SiteCreateModal from '~/components/sites/SiteCreateModal.vue'
|
|||||||
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
|
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
|
||||||
import { useSiteManagement } from '~/composables/useSiteManagement'
|
import { useSiteManagement } from '~/composables/useSiteManagement'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sites,
|
sites,
|
||||||
loading,
|
loading,
|
||||||
|
|||||||
@@ -127,6 +127,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Commentaires -->
|
||||||
|
<CommentSection
|
||||||
|
entity-type="machine_skeleton"
|
||||||
|
:entity-id="type.id"
|
||||||
|
:entity-name="type.name"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error State -->
|
<!-- Error State -->
|
||||||
@@ -153,6 +160,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { getMachineTypeById } = useMachineTypesApi()
|
const { getMachineTypeById } = useMachineTypesApi()
|
||||||
const { showError } = useToast()
|
const { showError } = useToast()
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
|
const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
|
||||||
@@ -204,6 +205,10 @@ const saveChanges = async () => {
|
|||||||
|
|
||||||
// Charger le type au montage
|
// Charger le type au montage
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (!canEdit.value) {
|
||||||
|
router.replace(`/type/${route.params.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const typeId = route.params.id
|
const typeId = route.params.id
|
||||||
const result = await getMachineTypeById(typeId, true)
|
const result = await getMachineTypeById(typeId, true)
|
||||||
|
|||||||
@@ -132,28 +132,19 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
if (params.category) {
|
if (params.category) {
|
||||||
query.category = params.category;
|
query.category = params.category;
|
||||||
}
|
}
|
||||||
if (params.sort) {
|
|
||||||
query.sort = params.sort;
|
|
||||||
}
|
|
||||||
if (params.dir) {
|
|
||||||
query.dir = params.dir;
|
|
||||||
}
|
|
||||||
const hasCategoryFilter = Boolean(params.category);
|
|
||||||
const effectiveLimit = typeof params.limit === 'number' ? params.limit : undefined;
|
|
||||||
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
|
||||||
|
|
||||||
if (hasCategoryFilter) {
|
// Sort: API Platform OrderFilter uses order[field]=direction
|
||||||
// Fetch enough items to allow client-side category filtering + pagination.
|
const sortField = params.sort || 'name';
|
||||||
query.itemsPerPage = Math.max(effectiveLimit ?? 200, 200);
|
const sortDir = params.dir || 'asc';
|
||||||
query.offset = 0;
|
query[`order[${sortField}]`] = sortDir;
|
||||||
} else {
|
|
||||||
if (typeof params.limit === 'number') {
|
// Pagination: API Platform uses page + itemsPerPage
|
||||||
query.itemsPerPage = params.limit;
|
const effectiveLimit = typeof params.limit === 'number' ? params.limit : 20;
|
||||||
}
|
const effectiveOffset = typeof params.offset === 'number' ? params.offset : 0;
|
||||||
if (typeof params.offset === 'number') {
|
const page = Math.floor(effectiveOffset / effectiveLimit) + 1;
|
||||||
query.offset = params.offset;
|
|
||||||
}
|
query.itemsPerPage = effectiveLimit;
|
||||||
}
|
query.page = page;
|
||||||
|
|
||||||
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
const payload = await requestFetch<Record<string, any>>(ENDPOINT, createOptions({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -168,25 +159,20 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
: Array.isArray(payload?.items)
|
: Array.isArray(payload?.items)
|
||||||
? payload.items
|
? payload.items
|
||||||
: [];
|
: [];
|
||||||
const filteredItems = params.category
|
|
||||||
? rawItems.filter((item: any) => item?.category === params.category)
|
const total = typeof payload?.totalItems === 'number'
|
||||||
: rawItems;
|
? payload.totalItems
|
||||||
const total = params.category
|
: typeof payload?.['hydra:totalItems'] === 'number'
|
||||||
? filteredItems.length
|
? payload['hydra:totalItems']
|
||||||
: typeof payload?.totalItems === 'number'
|
: rawItems.length;
|
||||||
? payload.totalItems
|
|
||||||
: Array.isArray(payload?.items)
|
const items = rawItems.map(normalizeModelType);
|
||||||
? payload.items.length
|
|
||||||
: rawItems.length;
|
|
||||||
const items = (params.category && typeof effectiveLimit === 'number'
|
|
||||||
? filteredItems.slice(effectiveOffset, effectiveOffset + effectiveLimit)
|
|
||||||
: filteredItems).map(normalizeModelType);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
offset: effectiveOffset,
|
offset: effectiveOffset,
|
||||||
limit: typeof effectiveLimit === 'number' ? effectiveLimit : items.length,
|
limit: effectiveLimit,
|
||||||
} satisfies ModelTypeListResponse;
|
} satisfies ModelTypeListResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,3 +219,33 @@ export function getModelType(id: string, opts: { signal?: AbortSignal } = {}) {
|
|||||||
signal: opts.signal,
|
signal: opts.signal,
|
||||||
})).then(normalizeModelType);
|
})).then(normalizeModelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConversionCheck {
|
||||||
|
canConvert: boolean;
|
||||||
|
direction: 'piece_to_component' | 'component_to_piece' | null;
|
||||||
|
itemCount: number;
|
||||||
|
names: string[];
|
||||||
|
blockers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversionResult {
|
||||||
|
success: boolean;
|
||||||
|
convertedCount: number;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkConversion(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ConversionCheck>(`${ENDPOINT}/${id}/conversion-check`, createOptions({
|
||||||
|
method: 'GET',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertCategory(id: string, opts: { signal?: AbortSignal } = {}) {
|
||||||
|
const requestFetch = useRequestFetch();
|
||||||
|
return requestFetch<ConversionResult>(`${ENDPOINT}/${id}/convert`, createOptions({
|
||||||
|
method: 'POST',
|
||||||
|
signal: opts.signal,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
229
migration.md
Normal file
229
migration.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Plan de migration — Réduction de code frontend
|
||||||
|
|
||||||
|
> Objectif : réduire ~5 700 LOC sans modifier le fonctionnel.
|
||||||
|
> Branche : à partir de `refacto/F1-decoupage-mega-composants`
|
||||||
|
> Statut global : **EN ATTENTE**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Pages catalogue (3 pages, ~1 200 LOC → ~350 LOC)
|
||||||
|
|
||||||
|
### M1.1 · Composant générique `CatalogPage.vue`
|
||||||
|
|
||||||
|
- **Motif** : `component-catalog.vue` (348 LOC), `pieces-catalog.vue` (463 LOC) et `product-catalog.vue` (408 LOC) partagent 95 % de structure (recherche, tri, pagination, tableau, suppression, états vides/loading).
|
||||||
|
- **Différences isolées** : colonnes du tableau, garde de suppression, extraction fournisseur.
|
||||||
|
- **Plan** :
|
||||||
|
1. Créer `app/components/common/CatalogPage.vue` acceptant :
|
||||||
|
- `columns: ColumnDef[]` (nom, clé, slot optionnel)
|
||||||
|
- `fetchFn: (params) => Promise<PaginatedResult>`
|
||||||
|
- `deleteFn: (id) => Promise<Result>`
|
||||||
|
- `deleteGuard?: (item) => string | null` (message bloquant ou null)
|
||||||
|
- `entityLabel: string`, `createRoute: string`
|
||||||
|
- Slots nommés pour colonnes custom (`#col-supplier`, etc.)
|
||||||
|
2. Extraire `supplierDisplayUtils.ts` (pattern `MAX_VISIBLE_SUPPLIERS` dupliqué dans pieces-catalog et product-catalog).
|
||||||
|
3. Réduire chaque page catalogue à ~80 LOC (config + slots custom).
|
||||||
|
- **Gain estimé** : ~850 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Composables CRUD génériques (~1 170 LOC → ~400 LOC)
|
||||||
|
|
||||||
|
### M2.1 · Factory `useEntityCRUD<T>(config)`
|
||||||
|
|
||||||
|
- **Motif** : `usePieces.ts` (240), `useProducts.ts` (305), `useComposants.ts` (231), `useSites.ts` (124) suivent le même pattern CRUD : refs `loading/loaded/error`, `loadItems()` paginé, `create/update/delete` avec mise à jour cache + toast.
|
||||||
|
- **Différences isolées** : endpoint, normaliseur, enrichissement constructeurs, champs de tri.
|
||||||
|
- **Plan** :
|
||||||
|
1. Créer `app/composables/useEntityCRUD.ts` :
|
||||||
|
```ts
|
||||||
|
interface EntityCRUDConfig {
|
||||||
|
endpoint: string
|
||||||
|
label: string
|
||||||
|
normalizer?: (item: any) => any
|
||||||
|
enricher?: (item: any) => Promise<any>
|
||||||
|
defaultSort?: { field: string; dir: 'asc' | 'desc' }
|
||||||
|
}
|
||||||
|
export function useEntityCRUD(config: EntityCRUDConfig)
|
||||||
|
```
|
||||||
|
2. Extraire `extractTotal()` dans `apiHelpers.ts` (dupliqué 3×, ~10 LOC chacun).
|
||||||
|
3. Extraire `buildPaginatedQuery(options)` dans `apiHelpers.ts` (dupliqué 3×, ~15 LOC chacun).
|
||||||
|
4. Extraire pattern `withResolvedConstructeurs()` dans `useEntityEnricher.ts` (dupliqué 3× dans pieces/products/composants, ~50 LOC chacun).
|
||||||
|
5. Réduire chaque composable à un appel de factory + méthodes spécifiques.
|
||||||
|
6. Garder `useMachines.ts` séparé (méthodes spéciales : `reconfigureSkeleton`, `createMachineFromType`).
|
||||||
|
- **Gain estimé** : ~770 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M2.2 · Helper `withLoadingState()`
|
||||||
|
|
||||||
|
- **Motif** : pattern `loading.value = true; try { ... } finally { loading.value = false }` répété 10+ fois dans les composables CRUD.
|
||||||
|
- **Plan** : créer `app/composables/useLoadingHelper.ts` exportant :
|
||||||
|
```ts
|
||||||
|
async function withLoadingState<T>(loading: Ref<boolean>, fn: () => Promise<T>): Promise<T>
|
||||||
|
```
|
||||||
|
- **Gain estimé** : ~100 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M2.3 · Fusion `usePersistedValue` + `usePersistedSort`
|
||||||
|
|
||||||
|
- **Motif** : même pattern `useCookie()` + `watch()` + JSON parse/stringify.
|
||||||
|
- **Plan** : fusionner en `usePersistedState<T>(key, fallback, prefix?)`.
|
||||||
|
- **Gain estimé** : ~30 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Pages edit entités (~2 750 LOC → ~1 200 LOC)
|
||||||
|
|
||||||
|
### M3.1 · Composant `HistorySection.vue`
|
||||||
|
|
||||||
|
- **Motif** : bloc historique identique (loading/error/empty + itération entries) dans `component/[id]/edit.vue` (L437-503), `pieces/[id]/edit.vue` (L384-450), `product/[id]/edit.vue` (L304-370) — ~67 LOC × 3.
|
||||||
|
- **Plan** : créer `app/components/common/HistorySection.vue` avec props `entries`, `loading`, `error`.
|
||||||
|
- **Gain estimé** : ~130 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M3.2 · Composant `DocumentsSection.vue`
|
||||||
|
|
||||||
|
- **Motif** : bloc document (upload, liste, preview, download, delete) dupliqué dans les 3 pages edit + `MachineDocumentsCard.vue` + `SiteEditModal.vue` — ~70-180 LOC × 5.
|
||||||
|
- **Plan** : créer `app/components/common/DocumentsSection.vue` avec props `documents`, `entityId`, `entityType` et events `upload`, `delete`, `preview`.
|
||||||
|
- **Gain estimé** : ~400 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M3.3 · Composable `useEntityEditForm(config)`
|
||||||
|
|
||||||
|
- **Motif** : les 3 pages edit partagent : chargement entité + types + constructeurs, gestion champs custom, normalisation payload, sauvegarde, gestion erreur.
|
||||||
|
- **Différences** : component a structure display, piece a product selection, product est plus simple.
|
||||||
|
- **Plan** :
|
||||||
|
1. Créer `app/composables/useEntityEditForm.ts` gérant le cycle de vie commun (load, save, custom fields sync).
|
||||||
|
2. Chaque page edit ne garde que ses spécificités.
|
||||||
|
- **Gain estimé** : ~500 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M3.4 · Réutilisation `customFieldFormUtils.ts` dans `component/create.vue`
|
||||||
|
|
||||||
|
- **Motif** : `component/create.vue` (1 266 LOC) réimplémente `resolveFieldName`, `resolveFieldType`, `resolveDefaultValue` déjà dans `customFieldFormUtils.ts`. Aussi 3 fonctions `resolveXxxLabel` quasi-identiques (~18 LOC × 3).
|
||||||
|
- **Plan** :
|
||||||
|
1. Remplacer les fonctions locales par les imports de `customFieldFormUtils.ts`.
|
||||||
|
2. Créer `resolveTypeLabel(entity, typeField, labelField, fallback)` générique.
|
||||||
|
- **Gain estimé** : ~120 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Décomposition `useMachineDetailData.ts` (1 410 LOC → ~500 LOC)
|
||||||
|
|
||||||
|
### M4.1 · Extraire `useMachineDocuments.ts`
|
||||||
|
|
||||||
|
- **Motif** : gestion documents (upload, delete, preview, refresh) = ~200 LOC dans le composable monolithique.
|
||||||
|
- **Gain estimé** : ~150 LOC (après factorisation avec DocumentsSection)
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M4.2 · Extraire `useMachineConstructeurs.ts`
|
||||||
|
|
||||||
|
- **Motif** : résolution constructeurs avec chaînes de fallback 4 niveaux, `uniqueConstructeurIds`, `resolveConstructeurs` = ~80 LOC.
|
||||||
|
- **Gain estimé** : ~60 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M4.3 · Fusionner `transformCustomFields` et `transformComponentCustomFields`
|
||||||
|
|
||||||
|
- **Motif** : L303-405 et L407-514 — logique quasi-identique de transformation des champs custom, seule la source (machine vs composant) diffère.
|
||||||
|
- **Plan** : créer `transformEntityCustomFields(entity, fieldSource, config)` paramétrable.
|
||||||
|
- **Gain estimé** : ~100 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M4.4 · Extraire groupement de requirements
|
||||||
|
|
||||||
|
- **Motif** : `componentRequirementGroups`, `pieceRequirementGroups` = computed complexes avec construction de maps et filtres répétitifs.
|
||||||
|
- **Gain estimé** : ~80 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — `StructureNodeEditor.vue` (1 167 LOC → ~600 LOC)
|
||||||
|
|
||||||
|
### M5.1 · Composable `useDragDrop.ts`
|
||||||
|
|
||||||
|
- **Motif** : 4 handlers drag-drop quasi-identiques (custom fields, pièces, produits, sous-composants) avec chacun `draggingIndex`, `dropTargetIndex`, `reorderClass()`, `handleDragStart/Over/End`.
|
||||||
|
- **Plan** : créer `useDragDrop<T>(items: Ref<T[]>)` retournant `{ dragging, target, reorderClass, onDragStart, onDragOver, onDragEnd, onDrop }`.
|
||||||
|
- **Gain estimé** : ~350 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M5.2 · Extraire validation noeud
|
||||||
|
|
||||||
|
- **Motif** : `isAssignmentNodeComplete` + logique de validation dispersée.
|
||||||
|
- **Plan** : déplacer vers `app/shared/utils/structureValidation.ts`.
|
||||||
|
- **Gain estimé** : ~40 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6 — Micro-duplications restantes (du `micro-dup-report.md`)
|
||||||
|
|
||||||
|
### M6.1 · `useControlledModel.ts` (MDUP-004)
|
||||||
|
|
||||||
|
- **Motif** : `computed({ get, set })` pour transiter `v-model` entre props et emits — dupliqué dans 6 composants.
|
||||||
|
- **Gain estimé** : ~60 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M6.2 · `ModalShell.vue` (MDUP-008) + `ModalActions.vue` (MDUP-007)
|
||||||
|
|
||||||
|
- **Motif** : squelette de modale DaisyUI (`.modal` + `.modal-box` + titre + footer) dupliqué dans 4+ composants. Pieds de modale « Annuler + Primaire + spinner » dupliqués 5×.
|
||||||
|
- **Gain estimé** : ~120 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M6.3 · `LoadingButton.vue` (MDUP-010) + `FieldText.vue` (MDUP-009)
|
||||||
|
|
||||||
|
- **Motif** : bouton primaire avec spinner (3 occurrences), champ texte simple label+input (5 occurrences).
|
||||||
|
- **Gain estimé** : ~80 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
### M6.4 · `createRequirementDefaults` + `useEnsureOptionsLoaded` (MDUP-005, MDUP-006)
|
||||||
|
|
||||||
|
- **Motif** : factory de requirement par défaut + `onMounted` identiques dans les sections composant/pièce.
|
||||||
|
- **Gain estimé** : ~30 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Consolidation custom fields (~1 150 LOC → ~800 LOC)
|
||||||
|
|
||||||
|
### M7.1 · Fusionner logique de résolution dans `customFieldUtils.ts`
|
||||||
|
|
||||||
|
- **Motif** : `customFieldUtils.ts` (440), `entityCustomFieldLogic.ts` (349), `customFieldFormUtils.ts` (367) contiennent des fonctions de résolution de champs qui se chevauchent (`resolveFieldId`, `resolveFieldName`, génération de clé, déduplication).
|
||||||
|
- **Plan** : consolider les fonctions dupliquées en gardant la séparation thématique (utils / form / entity) mais en partageant les primitives.
|
||||||
|
- **Gain estimé** : ~150 LOC
|
||||||
|
- **Statut** : `[ ]`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Récapitulatif
|
||||||
|
|
||||||
|
| Phase | Cible | LOC avant | Gain estimé | Priorité |
|
||||||
|
|-------|-------|-----------|-------------|----------|
|
||||||
|
| **P1** | Pages catalogue | ~1 220 | ~850 | Haute |
|
||||||
|
| **P2** | Composables CRUD | ~1 170 | ~900 | Haute |
|
||||||
|
| **P3** | Pages edit entités | ~2 750 | ~1 150 | Haute |
|
||||||
|
| **P4** | useMachineDetailData | ~1 410 | ~390 | Moyenne |
|
||||||
|
| **P5** | StructureNodeEditor | ~1 167 | ~390 | Moyenne |
|
||||||
|
| **P6** | Micro-duplications | ~400 | ~290 | Basse |
|
||||||
|
| **P7** | Custom fields utils | ~1 150 | ~150 | Basse |
|
||||||
|
| | **Total** | | **~4 120 LOC** | |
|
||||||
|
|
||||||
|
### Ordre recommandé
|
||||||
|
|
||||||
|
1. **P2** (CRUD generics) — fondation pour P1 et P3
|
||||||
|
2. **P1** (catalogues) — dépend de P2 pour les fetch functions
|
||||||
|
3. **P3** (pages edit) — plus gros gain absolu, dépend partiellement de P2
|
||||||
|
4. **P5** (drag-drop) — indépendant, quick win
|
||||||
|
5. **P4** (machine detail) — complexe mais fort impact
|
||||||
|
6. **P6** (micro-dup) — petits gains, faible risque
|
||||||
|
7. **P7** (custom fields) — délicat, à faire en dernier
|
||||||
|
|
||||||
|
### Vérification après chaque phase
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd Inventory_frontend
|
||||||
|
npx nuxi typecheck # 0 erreurs
|
||||||
|
npm run lint:fix # 0 erreurs
|
||||||
|
npm run build # succès
|
||||||
|
npx vitest run # 54+ tests pass
|
||||||
|
```
|
||||||
@@ -41,7 +41,7 @@ export default defineNuxtConfig({
|
|||||||
|| process.env.NUXT_PUBLIC_API_BASE_URL
|
|| process.env.NUXT_PUBLIC_API_BASE_URL
|
||||||
|| 'http://localhost/api',
|
|| 'http://localhost/api',
|
||||||
public: {
|
public: {
|
||||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
|
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || '/api',
|
||||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||||
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
||||||
appVersion: appVersion,
|
appVersion: appVersion,
|
||||||
@@ -54,7 +54,15 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()]
|
plugins: [tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
css: ['~/assets/app.css'],
|
css: ['~/assets/app.css'],
|
||||||
router: {
|
router: {
|
||||||
|
|||||||
81
package-lock.json
generated
81
package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.68",
|
"@iconify-json/lucide": "^1.2.68",
|
||||||
"@nuxt/eslint-config": "^1.9.0",
|
"@nuxt/eslint-config": "^1.9.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@rushstack/eslint-patch": "^1.12.0",
|
"@rushstack/eslint-patch": "^1.12.0",
|
||||||
"@types/node": "^25.2.1",
|
"@types/node": "^25.2.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
"@typescript-eslint/eslint-plugin": "^8.44.1",
|
||||||
@@ -3202,6 +3203,22 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
@@ -5533,12 +5550,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.8.7",
|
"version": "2.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.7.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||||
"integrity": "sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==",
|
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"baseline-browser-mapping": "dist/cli.js"
|
"baseline-browser-mapping": "dist/cli.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
@@ -5830,9 +5850,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001745",
|
"version": "1.0.30001775",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001745.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz",
|
||||||
"integrity": "sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==",
|
"integrity": "sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
@@ -10828,6 +10848,53 @@
|
|||||||
"pathe": "^2.0.3"
|
"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": {
|
"node_modules/pluralize": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
"lint": "eslint . --ext .js,.ts,.vue",
|
"lint": "eslint . --ext .js,.ts,.vue",
|
||||||
"lint:fix": "npm run lint -- --fix",
|
"lint:fix": "npm run lint -- --fix",
|
||||||
"test": "vitest run",
|
"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": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
@@ -26,6 +29,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify-json/lucide": "^1.2.68",
|
"@iconify-json/lucide": "^1.2.68",
|
||||||
"@nuxt/eslint-config": "^1.9.0",
|
"@nuxt/eslint-config": "^1.9.0",
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@rushstack/eslint-patch": "^1.12.0",
|
"@rushstack/eslint-patch": "^1.12.0",
|
||||||
"@types/node": "^25.2.1",
|
"@types/node": "^25.2.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.44.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