13 Commits

Author SHA1 Message Date
Matthieu
e88ed5b8f2 feat(documents): migrate storage to filesystem, add server-side pagination
- Replace Base64 data URIs with file-based storage served via dedicated endpoints
- Add DocumentPreviewModal navigation, DocumentThumbnail fileUrl support
- Refactor documents page with server-side pagination, search, sort and filters
- Update all components to use fileUrl/downloadUrl instead of raw path
- Add pagination composable support (total, page, itemsPerPage, attachmentFilter)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:17:59 +01:00
Matthieu
546cc37a09 feat(catalog): add description column with hover popover + skeleton edit guard
- Add description column to pieces and component catalog tables
- Show full text in a popover on hover for truncated descriptions
- Block skeleton editing when machines are linked (warning alert)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 10:13:06 +01:00
Matthieu
efd0fbe407 feat(catalog) : add description textarea to piece and component forms
Add description field (textarea) between name and reference/fournisseur
on create and edit pages for both pieces and components.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:35:52 +01:00
Matthieu
607f84fc3d fix(sites): remove toRefs shadowing causing [object Object] in site name field 2026-03-02 16:33:30 +01:00
Matthieu
a98ab8c275 feat(comments): add comment/ticket system across all entity pages
Add CommentSection component for inline comments on entity detail pages
(machines, pieces, composants, products, categories, skeleton types).
Add dedicated /comments page with filters, pagination and clickable links.
Add unresolved count badge on avatar and in profile dropdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:06:06 +01:00
Matthieu
e22463874c fix(constructeurs): improve search filtering and duplicate prevention
Switch ConstructeurSelect to client-side filtering instead of debounced
API calls. Add duplicate name check before creating a new constructeur
in both ConstructeurSelect and the constructeurs page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:05:54 +01:00
Matthieu
256039264e chore: update package-lock.json
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:02:13 +01:00
Matthieu
e459da7c20 fix(ui) : replace checkbox with toggle switch for boolean custom fields
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:56:51 +01:00
Matthieu
e84b5cf674 feat(ui) : display role badge in profile dropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:42:05 +01:00
Matthieu
cc70fe2b29 feat(permissions) : add role-based UI guards and readonly mode for viewers
- Add usePermissions composable (isAdmin, canEdit, canView)
- Password-protected profile login with modal on profiles page
- Disable all form fields for ROLE_VIEWER across edit/create pages
- Show navigation buttons (Modifier/Consulter) for all roles, hide delete for viewers
- Add readonly prop to ModelTypeForm for category pages
- Disable modal fields (sites, constructeurs) for viewers
- Guard /admin routes in middleware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 13:36:42 +01:00
Matthieu
6bed715b7f fix(machines): fix skeleton creation — load all items + atomic creation
- Load composants/pieces/products with itemsPerPage: 200 instead of 30
  (root cause: only first 30 items were available in creation dropdowns)
- Rollback machine if skeleton PATCH fails (delete orphaned machine)
- Initialize custom fields after successful machine creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 10:39:45 +01:00
Matthieu
dbf8c8856b test(e2e) : add Playwright setup with product and category CRUD specs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 09:07:23 +01:00
Matthieu
62127a33f5 chore(release) : update changelog for v1.6.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 14:51:13 +01:00
73 changed files with 3203 additions and 823 deletions

5
.gitignore vendored
View File

@@ -22,3 +22,8 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
# Playwright
e2e/.auth/
playwright-report/
test-results/

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

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
@@ -174,8 +175,8 @@
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10" class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-12 w-10"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -332,8 +333,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >

View File

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

View File

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

View File

@@ -10,9 +10,12 @@
<div class="min-w-0"> <div class="min-w-0">
<h3 class="font-bold text-xl truncate"> <h3 class="font-bold text-xl truncate">
Prévisualisation Prévisualisation
<span v-if="navTotal > 1" class="text-base font-normal text-gray-500">
{{ activeIndex + 1 }} / {{ navTotal }}
</span>
</h3> </h3>
<p class="text-sm text-gray-500 truncate"> <p class="text-sm text-gray-500 truncate">
{{ document?.name || document?.filename }}<span v-if="documentDescription"> {{ documentDescription }}</span> {{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> &bull; {{ documentDescription }}</span>
</p> </p>
</div> </div>
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close"> <button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
@@ -20,15 +23,35 @@
</button> </button>
</header> </header>
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden"> <section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
<button
v-if="hasPrev"
type="button"
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document précédent (←)"
@click="goToPrev"
>
</button>
<button
v-if="hasNext"
type="button"
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
title="Document suivant (→)"
@click="goToNext"
>
</button>
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden"> <div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
<template v-if="previewType === 'image'"> <template v-if="previewType === 'image'">
<img :src="document?.path" alt="preview" class="max-h-full max-w-full object-contain"> <img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
</template> </template>
<template v-else-if="previewType === 'pdf'"> <template v-else-if="previewType === 'pdf'">
<iframe <iframe
:src="document?.path" :src="documentSrc"
class="w-full h-full bg-white" class="w-full h-full bg-white"
frameborder="0" frameborder="0"
title="Aperçu PDF" title="Aperçu PDF"
@@ -36,11 +59,11 @@
</template> </template>
<template v-else-if="previewType === 'audio'"> <template v-else-if="previewType === 'audio'">
<audio :src="document?.path" controls class="w-full" /> <audio :src="documentSrc" controls class="w-full" />
</template> </template>
<template v-else-if="previewType === 'video'"> <template v-else-if="previewType === 'video'">
<video :src="document?.path" controls class="w-full h-full bg-black" /> <video :src="documentSrc" controls class="w-full h-full bg-black" />
</template> </template>
<template v-else-if="previewType === 'text'"> <template v-else-if="previewType === 'text'">
@@ -80,31 +103,110 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { getPreviewType, describeDocument } from '~/utils/documentPreview' import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
const props = defineProps({ const props = defineProps({
document: { document: {
type: Object, type: Object,
default: null default: null,
}, },
visible: { visible: {
type: Boolean, type: Boolean,
default: false default: false,
} },
documents: {
type: Array,
default: () => [],
},
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const previewType = computed(() => getPreviewType(props.document)) // --- Carousel navigation ---
const documentDescription = computed(() => describeDocument(props.document))
const previewableDocuments = computed(() => {
if (!props.documents?.length) return []
return props.documents.filter((doc) => canPreviewDocument(doc))
})
const navTotal = computed(() => previewableDocuments.value.length)
const activeIndex = ref(0)
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
watch(
() => props.document,
(doc) => {
if (!doc || !previewableDocuments.value.length) {
activeIndex.value = 0
return
}
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
activeIndex.value = idx >= 0 ? idx : 0
},
{ immediate: true },
)
const activeDoc = computed(() => {
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
return previewableDocuments.value[activeIndex.value]
}
return props.document
})
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
const goToPrev = () => {
if (hasPrev.value) activeIndex.value--
}
const goToNext = () => {
if (hasNext.value) activeIndex.value++
}
// Keyboard navigation
const handleKeydown = (e) => {
if (!props.visible) return
if (e.key === 'ArrowLeft') {
e.preventDefault()
goToPrev()
} else if (e.key === 'ArrowRight') {
e.preventDefault()
goToNext()
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}
watch(
() => props.visible,
(val) => {
if (val) {
document.addEventListener('keydown', handleKeydown)
} else {
document.removeEventListener('keydown', handleKeydown)
}
},
)
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// --- Preview logic (uses activeDoc) ---
const previewType = computed(() => getPreviewType(activeDoc.value))
const documentDescription = computed(() => describeDocument(activeDoc.value))
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
const textContent = ref('') const textContent = ref('')
const textLoading = ref(false) const textLoading = ref(false)
const textError = ref('') const textError = ref('')
watch( watch(
() => props.document, activeDoc,
async (doc) => { async (doc) => {
textContent.value = '' textContent.value = ''
textError.value = '' textError.value = ''
@@ -115,22 +217,17 @@ watch(
try { try {
textLoading.value = true textLoading.value = true
const path = doc.path || '' const url = doc.fileUrl || doc.path || ''
if (path.startsWith('data:')) { if (!url) {
const base64Part = path.split(',')[1] || '' textError.value = 'Aucune URL de document disponible.'
if (!base64Part) { return
textError.value = 'Impossible de lire ce document texte.'
return
}
const decoded = atob(base64Part)
textContent.value = decodeURIComponent(escape(decoded))
} else {
const response = await fetch(path)
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} }
const response = await fetch(url, { credentials: 'include' })
if (!response.ok) {
throw new Error('Téléchargement du document impossible')
}
textContent.value = await response.text()
} catch (error) { } catch (error) {
console.error('Erreur lors du chargement du texte:', error) console.error('Erreur lors du chargement du texte:', error)
textError.value = error.message || 'Impossible de lire ce document.' textError.value = error.message || 'Impossible de lire ce document.'
@@ -138,7 +235,7 @@ watch(
textLoading.value = false textLoading.value = false
} }
}, },
{ immediate: true } { immediate: true },
) )
const close = () => { const close = () => {
@@ -146,11 +243,8 @@ const close = () => {
} }
const download = () => { const download = () => {
if (!props.document?.path) { return } const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
const link = document.createElement('a') if (!url) { return }
link.href = props.document.path window.open(url, '_blank')
link.download = props.document.filename || props.document.name || 'document'
link.target = '_blank'
link.click()
} }
</script> </script>

View File

@@ -40,6 +40,8 @@ type GenericDocument = {
filename?: string | null; filename?: string | null;
mimeType?: string | null; mimeType?: string | null;
path?: string | null; path?: string | null;
fileUrl?: string | null;
downloadUrl?: string | null;
size?: number | null; size?: number | null;
}; };
@@ -52,7 +54,7 @@ const normalizedDocument = computed(() => props.document ?? null);
const canRenderImage = computed(() => { const canRenderImage = computed(() => {
const doc = normalizedDocument.value; const doc = normalizedDocument.value;
return !!(doc && isImageDocument(doc) && doc.path); return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
}); });
const canRenderPdf = computed(() => { const canRenderPdf = computed(() => {
@@ -73,13 +75,14 @@ const appendPdfViewerParams = (src: string) => {
const previewSrc = computed(() => { const previewSrc = computed(() => {
const doc = normalizedDocument.value; const doc = normalizedDocument.value;
if (!doc || !doc.path) { const url = doc?.fileUrl || doc?.path;
if (!doc || !url) {
return ''; return '';
} }
if (isPdfDocument(doc)) { if (isPdfDocument(doc)) {
return appendPdfViewerParams(doc.path); return appendPdfViewerParams(url);
} }
return doc.path; return url;
}); });
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16')); const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
@@ -184,8 +185,8 @@
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8" class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -413,8 +414,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >

View File

@@ -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,12 @@ const navGroups: NavGroup[] = [
{ {
id: 'resources', id: 'resources',
label: 'Ressources liées', label: 'Ressources liées',
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'], 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é' }, { to: '/activity-log', label: 'Journal d\'activité' },
], ],
}, },
@@ -288,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 === '/') {
@@ -318,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'

View File

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

View File

@@ -32,8 +32,8 @@
:class="documentThumbnailClass(doc)" :class="documentThumbnailClass(doc)"
> >
<img <img
v-if="isImageDocument(doc) && doc.path" v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
:src="doc.path" :src="doc.fileUrl || doc.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${doc.name}`" :alt="`Aperçu de ${doc.name}`"
> >

View File

@@ -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 ?? ''"

View File

@@ -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"
@@ -30,6 +31,7 @@
:limit="limit" :limit="limit"
:offset="offset" :offset="offset"
:category="selectedCategory" :category="selectedCategory"
:can-edit="canEdit"
@related="openRelatedModal" @related="openRelatedModal"
@edit="openEditPage" @edit="openEditPage"
@delete="confirmDelete" @delete="confirmDelete"
@@ -169,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(

View File

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

View File

@@ -49,7 +49,7 @@
Liés Liés
</button> </button>
<button <button
v-if="showConvertButton" v-if="canEdit && showConvertButton"
type="button" type="button"
class="btn btn-ghost btn-sm text-warning" class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)" @click="emit('convert', item)"
@@ -60,7 +60,7 @@
<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>
@@ -88,7 +88,7 @@
Liés Liés
</button> </button>
<button <button
v-if="showConvertButton" v-if="canEdit && showConvertButton"
type="button" type="button"
class="btn btn-ghost btn-sm text-warning" class="btn btn-ghost btn-sm text-warning"
@click="emit('convert', item)" @click="emit('convert', item)"
@@ -99,7 +99,7 @@
<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>
@@ -146,6 +146,7 @@ const props = defineProps<{
limit: number; limit: number;
offset: number; offset: number;
category?: ModelCategory; category?: ModelCategory;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -90,6 +90,7 @@ const props = defineProps<{
dir: SortDirection; dir: SortDirection;
loading?: boolean; loading?: boolean;
showCategoryTabs?: boolean; showCategoryTabs?: boolean;
canEdit?: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{

View File

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

View File

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

View File

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

View File

@@ -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..."
@@ -55,8 +57,8 @@
<div class="flex items-center gap-3 text-sm"> <div class="flex items-center gap-3 text-sm">
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"> <div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center">
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -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>
@@ -114,7 +116,7 @@
</template> </template>
<script setup> <script setup>
import { computed, toRefs } from 'vue' import { computed } from 'vue'
import { isImageDocument } from '~/utils/documentPreview' import { isImageDocument } from '~/utils/documentPreview'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue' import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
@@ -155,6 +157,10 @@ const props = defineProps({
formatSize: { formatSize: {
type: Function, type: Function,
required: true required: true
},
disabled: {
type: Boolean,
default: false
} }
}) })
@@ -167,8 +173,6 @@ const emit = defineEmits([
'update:selectedFiles' 'update:selectedFiles'
]) ])
const form = toRefs(props.form)
const selectedFilesModel = computed({ const selectedFilesModel = computed({
get: () => props.selectedFiles, get: () => props.selectedFiles,
set: value => emit('update:selectedFiles', value) set: value => emit('update:selectedFiles', value)

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

View File

@@ -20,11 +20,10 @@ export function useApi() {
const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => { const apiCall = async <T = any>(endpoint: string, options: ApiCallOptions = {}): Promise<ApiResponse<T>> => {
const url = `${API_BASE_URL}${endpoint}` const url = `${API_BASE_URL}${endpoint}`
const isFormData = options.body instanceof FormData
const defaultOptions: ApiCallOptions = { const defaultOptions: ApiCallOptions = {
credentials: 'include', credentials: 'include',
headers: { headers: isFormData ? {} : { 'Content-Type': 'application/json' },
'Content-Type': 'application/json',
},
} }
// Ajouter un timeout à la requête // Ajouter un timeout à la requête
@@ -66,7 +65,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 }
} }
@@ -113,6 +114,13 @@ export function useApi() {
}) })
} }
const postFormData = async <T = any>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, {
method: 'POST',
body: formData,
})
}
const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => { const del = async <T = any>(endpoint: string): Promise<ApiResponse<T>> => {
return apiCall<T>(endpoint, { method: 'DELETE' }) return apiCall<T>(endpoint, { method: 'DELETE' })
} }
@@ -121,6 +129,7 @@ export function useApi() {
apiCall, apiCall,
get, get,
post, post,
postFormData,
patch, patch,
put, put,
delete: del, delete: del,

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

View File

@@ -10,6 +10,7 @@ export interface Composant {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
description?: string | null
typeComposantId?: string | null typeComposantId?: string | null
typeComposant?: { id: string; name?: string } | null typeComposant?: { id: string; name?: string } | null
productId?: string | null productId?: string | null

View File

@@ -1,7 +1,6 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection } from '~/shared/utils/apiHelpers'
export interface Document { export interface Document {
@@ -10,12 +9,21 @@ export interface Document {
filename: string filename: string
mimeType: string mimeType: string
size: number size: number
path: string fileUrl: string
downloadUrl: string
/** @deprecated Legacy Base64 data URI — use fileUrl instead */
path?: string
createdAt?: string
siteId?: string siteId?: string
machineId?: string machineId?: string
composantId?: string composantId?: string
productId?: string productId?: string
pieceId?: string pieceId?: string
site?: { id: string; name?: string } | null
machine?: { id: string; name?: string } | null
composant?: { id: string; name?: string } | null
piece?: { id: string; name?: string } | null
product?: { id: string; name?: string } | null
} }
export interface UploadContext { export interface UploadContext {
@@ -32,19 +40,30 @@ export interface DocumentResult {
error?: string error?: string
} }
const documents = ref<Document[]>([]) interface LoadDocumentsOptions {
const loading = ref(false) search?: string
page?: number
itemsPerPage?: number
orderBy?: string
orderDir?: 'asc' | 'desc'
attachmentFilter?: string
force?: boolean
}
const fileToBase64 = (file: File): Promise<string> => const documents = ref<Document[]>([])
new Promise((resolve, reject) => { const total = ref(0)
const reader = new FileReader() const loading = ref(false)
reader.onload = () => resolve(reader.result as string) const loaded = ref(false)
reader.onerror = () => reject(new Error(`Lecture du fichier ${file.name} impossible`))
reader.readAsDataURL(file) const extractTotal = (payload: unknown, fallbackLength: number): number => {
}) const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() { export function useDocuments() {
const { get, post, delete: del } = useApi() const { get, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast() const { showError, showSuccess } = useToast()
const loadFromEndpoint = async ( const loadFromEndpoint = async (
@@ -76,10 +95,61 @@ export function useDocuments() {
} }
} }
const loadDocuments = async ( const loadDocuments = async (options: LoadDocumentsOptions = {}): Promise<DocumentResult> => {
options: { updateStore?: boolean; itemsPerPage?: number } = {}, const {
): Promise<DocumentResult> => { search = '',
return loadFromEndpoint('/documents', { updateStore: options.updateStore ?? true, itemsPerPage: options.itemsPerPage }) page = 1,
itemsPerPage = 30,
orderBy = 'createdAt',
orderDir = 'desc',
attachmentFilter = 'all',
force = false,
} = options
if (!force && loaded.value && !search && page === 1 && attachmentFilter === 'all') {
return { success: true, data: documents.value }
}
if (loading.value) {
return { success: true, data: documents.value }
}
loading.value = true
try {
const params = new URLSearchParams()
params.set('itemsPerPage', String(itemsPerPage))
params.set('page', String(page))
if (search && search.trim()) {
params.set('name', search.trim())
}
if (attachmentFilter && attachmentFilter !== 'all') {
params.set(`exists[${attachmentFilter}]`, 'true')
}
params.set(`order[${orderBy}]`, orderDir)
const result = await get(`/documents?${params.toString()}`)
if (result.success) {
const items = extractCollection(result.data)
documents.value = items
total.value = extractTotal(result.data, items.length)
loaded.value = true
return { success: true, data: items }
}
if (result.error) {
showError(result.error)
}
return result as DocumentResult
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement des documents:', error)
showError('Impossible de charger les documents')
return { success: false, error: err.message }
} finally {
loading.value = false
}
} }
const loadDocumentsBySite = async ( const loadDocumentsBySite = async (
@@ -145,18 +215,17 @@ export function useDocuments() {
try { try {
for (const file of files) { for (const file of files) {
const dataUrl = await fileToBase64(file) const formData = new FormData()
formData.append('file', file)
formData.append('name', file.name)
const payload = normalizeRelationIds({ if (context.siteId) formData.append('siteId', context.siteId)
name: file.name, if (context.machineId) formData.append('machineId', context.machineId)
filename: file.name, if (context.composantId) formData.append('composantId', context.composantId)
mimeType: file.type || 'application/octet-stream', if (context.productId) formData.append('productId', context.productId)
size: file.size, if (context.pieceId) formData.append('pieceId', context.pieceId)
path: dataUrl,
...context,
})
const result = await post('/documents', payload) const result = await postFormData('/documents', formData)
if (result.success) { if (result.success) {
created.push(result.data as Document) created.push(result.data as Document)
showSuccess(`Document "${file.name}" ajouté`) showSuccess(`Document "${file.name}" ajouté`)
@@ -213,7 +282,9 @@ export function useDocuments() {
return { return {
documents, documents,
total,
loading, loading,
loaded,
loadDocuments, loadDocuments,
loadDocumentsBySite, loadDocumentsBySite,
loadDocumentsByMachine, loadDocumentsByMachine,

View File

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

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

View File

@@ -10,6 +10,7 @@ export interface Piece {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
description?: string | null
typePieceId?: string | null typePieceId?: string | null
typePiece?: { id: string; name?: string } | null typePiece?: { id: string; name?: string } | null
productId?: string | null productId?: string | null

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ type SiteDocument = {
mimeType?: string mimeType?: string
size?: number size?: number
path?: string path?: string
fileUrl?: string
downloadUrl?: string
} }
type SiteWithDocuments = { type SiteWithDocuments = {
@@ -209,17 +211,23 @@ export function useSiteManagement() {
} }
const downloadDocument = (doc: SiteDocument) => { const downloadDocument = (doc: SiteDocument) => {
if (!doc?.path) return if (doc?.downloadUrl) {
window.open(doc.downloadUrl, '_blank')
return
}
if (doc.path.startsWith('data:')) { const url = doc?.fileUrl || doc?.path
if (!url) return
if (url.startsWith('data:')) {
const link = document.createElement('a') const link = document.createElement('a')
link.href = doc.path link.href = url
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(url, '_blank')
} }
const openPreview = (doc: SiteDocument) => { const openPreview = (doc: SiteDocument) => {

View File

@@ -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("/");
}
}
}
}); });

245
app/pages/admin/index.vue Normal file
View 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>

View File

@@ -69,6 +69,15 @@ const badgeClass = (type: ChangeType) => {
} }
const releases: Release[] = [ 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', version: 'v1.6.0',
date: '2026-02-12', date: '2026-02-12',

331
app/pages/comments.vue Normal file
View 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>

View File

@@ -116,6 +116,7 @@
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Description</th>
<th>Type de composant</th> <th>Type de composant</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
@@ -130,6 +131,15 @@
</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 class="max-w-xs">
<div v-if="component.description" class="group relative">
<span class="block cursor-help truncate">{{ component.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ component.description }}</p>
</div>
</div>
<span v-else></span>
</td>
<td> <td>
<NuxtLink <NuxtLink
v-if="component.typeComposant?.id" v-if="component.typeComposant?.id"
@@ -149,6 +159,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"
@@ -185,6 +196,7 @@ 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()
@@ -267,7 +279,7 @@ const resolvePrimaryDocument = (component: Record<string, any>) => {
return null return null
} }
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path) const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc)) const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) { if (pdf) {
return pdf return pdf

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="componentDocuments"
@close="closePreview" @close="closePreview"
/> />
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
@@ -72,13 +73,26 @@
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
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -88,7 +102,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 +114,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 +132,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 +291,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 +300,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 +318,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 +343,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 +361,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"
@@ -373,8 +387,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -419,6 +433,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 +526,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 +591,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()
@@ -613,6 +639,7 @@ const historyDiffEntries = (entry: ComponentHistoryEntry) =>
const selectedTypeId = ref<string>('') const selectedTypeId = ref<string>('')
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -746,6 +773,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 &&
@@ -792,6 +820,7 @@ watch(
selectedTypeId.value = resolvedTypeId selectedTypeId.value = resolvedTypeId
editionForm.name = currentComponent.name || '' editionForm.name = currentComponent.name || ''
editionForm.description = currentComponent.description || ''
editionForm.reference = currentComponent.reference || '' editionForm.reference = currentComponent.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( editionForm.constructeurIds = uniqueConstructeurIds(
currentComponent, currentComponent,
@@ -832,6 +861,7 @@ const submitEdition = async () => {
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
} }
const reference = editionForm.reference.trim() const reference = editionForm.reference.trim()

View File

@@ -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,13 +45,26 @@
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
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -61,7 +74,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 +86,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 +103,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 +257,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 +266,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 +284,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 +309,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 +327,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,12 +414,14 @@ 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)
const submitting = ref(false) const submitting = ref(false)
const creationForm = reactive({ const creationForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -755,6 +770,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 &&
@@ -887,6 +903,7 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.description = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurIds = [] creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
@@ -904,6 +921,11 @@ const submitCreation = async () => {
typeComposantId: selectedType.value.id, typeComposantId: selectedType.value.id,
} }
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim() const reference = creationForm.reference.trim()
if (reference) { if (reference) {
payload.reference = reference payload.reference = reference

View File

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

View File

@@ -3,46 +3,107 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="documents"
@close="closePreview" @close="closePreview"
/> />
<section class="card bg-base-100 shadow-lg"> <section class="card bg-base-100 shadow-lg">
<div class="card-body space-y-6"> <div class="card-body space-y-6">
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> <div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div class="w-full md:w-2/3"> <div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<label class="label"> <label class="w-full sm:w-72">
<span class="label-text">Recherche</span> <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom du document..."
@input="debouncedSearch"
/>
</label> </label>
<input
v-model="searchTerm" <div class="flex items-center gap-2">
type="search" <label
placeholder="Nom du document, type, site, machine..." class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
class="input input-bordered w-full" for="doc-filter"
> >
Rattachement
</label>
<select
id="doc-filter"
v-model="attachmentFilter"
class="select select-bordered select-sm"
@change="handleFilterChange"
>
<option value="all">Tous</option>
<option value="site">Sites</option>
<option value="machine">Machines</option>
<option value="composant">Composants</option>
<option value="piece">Pi&egrave;ces</option>
<option value="product">Produits</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-sort"
>
Trier par
</label>
<select
id="doc-sort"
v-model="sortField"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="createdAt">Date</option>
<option value="name">Nom</option>
<option value="size">Taille</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-dir"
>
Ordre
</label>
<select
id="doc-dir"
v-model="sortDirection"
class="select select-bordered select-sm"
@change="handleSortChange"
>
<option value="asc">Ascendant</option>
<option value="desc">Descendant</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-per-page"
>
Par page
</label>
<select
id="doc-per-page"
v-model.number="itemsPerPage"
class="select select-bordered select-sm"
@change="handlePerPageChange"
>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="100">100</option>
</select>
</div>
</div> </div>
<div class="w-full md:w-1/3"> <p class="text-xs text-base-content/50 lg:text-right">
<label class="label"> {{ documentsOnPage }} / {{ documentsTotal }} r&eacute;sultat{{ documentsTotal > 1 ? 's' : '' }}
<span class="label-text">Filtrer par rattachement</span> </p>
</label>
<select v-model="attachmentFilter" class="select select-bordered w-full">
<option value="all">
Tous
</option>
<option value="site">
Sites
</option>
<option value="machine">
Machines
</option>
<option value="composant">
Composants
</option>
<option value="piece">
Pièces
</option>
</select>
</div>
</div> </div>
<div class="divider my-0" /> <div class="divider my-0" />
@@ -52,181 +113,191 @@
Chargement des documents... Chargement des documents...
</div> </div>
<div v-else-if="filteredDocuments.length === 0" class="text-center py-16 text-sm text-gray-500"> <div v-else-if="!documentsTotal" class="text-center py-16 text-sm text-gray-500">
<IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" /> <IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
Aucun document ne correspond à votre recherche pour l'instant. Aucun document n'a encore &eacute;t&eacute; ajout&eacute;.
</div> </div>
<div v-else class="overflow-x-auto"> <div v-else-if="!documents.length" class="text-center py-16 text-sm text-gray-500">
<table class="table"> <IconLucideFileSearch class="mx-auto mb-4 h-14 w-14 text-gray-400" aria-hidden="true" />
<thead> Aucun document ne correspond &agrave; votre recherche.
<tr class="text-xs uppercase"> </div>
<th>Nom</th>
<th>Type</th> <template v-else>
<th>Taille</th> <div class="overflow-x-auto">
<th>Rattaché à</th> <table class="table">
<th>Date</th> <thead>
<th class="text-right"> <tr class="text-xs uppercase">
Actions <th>Nom</th>
</th> <th>Type</th>
</tr> <th>Taille</th>
</thead> <th>Rattach&eacute; &agrave;</th>
<tbody> <th>Date</th>
<tr v-for="document in filteredDocuments" :key="document.id" class="text-sm"> <th class="text-right">Actions</th>
<td> </tr>
<div class="flex items-center gap-3"> </thead>
<span class="text-xl" :class="documentIcon(document).colorClass"> <tbody>
<component <tr v-for="doc in documents" :key="doc.id" class="text-sm">
:is="documentIcon(document).component" <td>
class="h-6 w-6" <div class="flex items-center gap-3">
aria-hidden="true" <span class="text-xl" :class="documentIcon(doc).colorClass">
/> <component
</span> :is="documentIcon(doc).component"
<div> class="h-6 w-6"
<div class="font-semibold"> aria-hidden="true"
{{ document.name }} />
</div> </span>
<div class="text-xs text-gray-500"> <div>
{{ document.filename }} <div class="font-semibold">{{ doc.name }}</div>
<div class="text-xs text-gray-500">{{ doc.filename }}</div>
</div> </div>
</div> </div>
</div> </td>
</td> <td>{{ doc.mimeType || 'Inconnu' }}</td>
<td>{{ document.mimeType || 'Inconnu' }}</td> <td>{{ formatSize(doc.size) }}</td>
<td>{{ formatSize(document.size) }}</td> <td>
<td> <div class="flex flex-col text-xs">
<div class="flex flex-col text-xs"> <span v-if="doc.site">Site &middot; {{ doc.site.name }}</span>
<span v-if="document.site">Site · {{ document.site.name }}</span> <span v-else-if="doc.machine">Machine &middot; {{ doc.machine.name }}</span>
<span v-else-if="document.machine">Machine · {{ document.machine.name }}</span> <span v-else-if="doc.composant">Composant &middot; {{ doc.composant.name }}</span>
<span v-else-if="document.composant">Composant · {{ document.composant.name }}</span> <span v-else-if="doc.piece">Pi&egrave;ce &middot; {{ doc.piece.name }}</span>
<span v-else-if="document.piece">Pièce · {{ document.piece.name }}</span> <span v-else-if="doc.product">Produit &middot; {{ doc.product.name }}</span>
<span v-else class="text-gray-400">Non défini</span> <span v-else class="text-gray-400">Non d&eacute;fini</span>
</div> </div>
</td> </td>
<td>{{ formatFrenchDate(document.createdAt) }}</td> <td>{{ formatFrenchDate(doc.createdAt) }}</td>
<td class="text-right"> <td class="text-right">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<button <button
class="btn btn-ghost btn-xs" class="btn btn-ghost btn-xs"
type="button" type="button"
:disabled="!canPreviewDocument(document)" :disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'" :title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
@click="openPreview(document)" @click="openPreview(doc)"
> >
Consulter Consulter
</button> </button>
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(document)"> <button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(doc)">
Télécharger T&eacute;l&eacute;charger
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination
:current-page="currentPage"
:total-pages="totalPages"
@update:current-page="handlePageChange"
/>
</template>
</div> </div>
</section> </section>
</main> </main>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useDocuments } from '~/composables/useDocuments' import { useDocuments } from '~/composables/useDocuments'
import { useApi } from '~/composables/useApi'
import { useUrlState } from '~/composables/useUrlState' 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'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import Pagination from '~/components/common/Pagination.vue'
import IconLucideFileSearch from '~icons/lucide/file-search' import IconLucideFileSearch from '~icons/lucide/file-search'
const { documents, loading, loadDocuments } = useDocuments() const { documents, total, loading, loadDocuments } = useDocuments()
const { get } = useApi()
const { q: searchTerm, filter: attachmentFilter } = useUrlState({ const {
page: currentPage,
perPage: itemsPerPage,
q: searchTerm,
filter: attachmentFilter,
sort: sortField,
dir: sortDirection,
} = useUrlState({
page: { default: 1, type: 'number' },
perPage: { default: 30, type: 'number' },
q: { default: '', debounce: 300 }, q: { default: '', debounce: 300 },
filter: { default: 'all' }, filter: { default: 'all' },
sort: { default: 'createdAt' },
dir: { default: 'desc' },
}, {
onRestore: () => fetchDocuments(),
}) })
const previewDocument = ref(null)
const previewDocument = ref<any>(null)
const previewVisible = ref(false) const previewVisible = ref(false)
onMounted(() => { const documentsTotal = computed(() => total.value)
loadDocuments({ itemsPerPage: 200 }) const documentsOnPage = computed(() => documents.value.length)
}) const totalPages = computed(() => Math.ceil(documentsTotal.value / itemsPerPage.value) || 1)
const filteredDocuments = computed(() => { const fetchDocuments = async () => {
const term = searchTerm.value.trim().toLowerCase() await loadDocuments({
const filter = attachmentFilter.value search: searchTerm.value,
page: currentPage.value,
return documents.value.filter((document) => { itemsPerPage: itemsPerPage.value,
const matchesFilter = orderBy: sortField.value,
filter === 'all' || orderDir: sortDirection.value as 'asc' | 'desc',
(filter === 'site' && document.site) || attachmentFilter: attachmentFilter.value,
(filter === 'machine' && document.machine) || force: true,
(filter === 'composant' && document.composant) ||
(filter === 'piece' && document.piece)
if (!matchesFilter) { return false }
if (!term) { return true }
const searchable = [
document.name,
document.filename,
document.mimeType,
document.site?.name,
document.machine?.name,
document.composant?.name,
document.piece?.name
]
.filter(Boolean)
.map(value => value.toLowerCase())
return searchable.some(value => value.includes(term))
}) })
}) }
const formatSize = (size) => { // Search debounce
if (size === undefined || size === null) { return '—' } let searchTimeout: ReturnType<typeof setTimeout> | null = null
if (size === 0) { return '0 B' }
const debouncedSearch = () => {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
currentPage.value = 1
fetchDocuments()
}, 300)
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchDocuments()
}
const handleSortChange = () => {
currentPage.value = 1
fetchDocuments()
}
const handleFilterChange = () => {
currentPage.value = 1
fetchDocuments()
}
const handlePerPageChange = () => {
currentPage.value = 1
fetchDocuments()
}
const formatSize = (size: number | undefined | null) => {
if (size === undefined || size === null) return '\u2014'
if (size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB'] const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))) const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index) const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}` return `${formatted.toFixed(1)} ${units[index]}`
} }
const documentIcon = doc => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType }) const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
/** Fetch the full document (with path) from the API on demand. */ const downloadDocument = (doc: any) => {
const fetchDocumentPath = async (doc) => { if (doc?.downloadUrl) {
if (doc?.path) { return doc.path } window.open(doc.downloadUrl, '_blank')
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
} }
const downloadDocument = async (doc) => { const openPreview = (doc: any) => {
const path = await fetchDocumentPath(doc) if (!canPreviewDocument(doc)) return
if (!path) { return }
if (path.startsWith('data:')) {
const link = document.createElement('a')
link.href = path
link.download = doc.filename || doc.name || 'document'
link.click()
return
}
window.open(path, '_blank')
}
const openPreview = async (doc) => {
if (!canPreviewDocument(doc)) { return }
await fetchDocumentPath(doc)
previewDocument.value = doc previewDocument.value = doc
previewVisible.value = true previewVisible.value = true
} }
@@ -235,4 +306,8 @@ const closePreview = () => {
previewVisible.value = false previewVisible.value = false
previewDocument.value = null previewDocument.value = null
} }
onMounted(() => {
fetchDocuments()
})
</script> </script>

View File

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

View File

@@ -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();

View File

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

View File

@@ -10,6 +10,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="d.previewDocument.value" :document="d.previewDocument.value"
:visible="d.previewVisible.value" :visible="d.previewVisible.value"
:documents="d.machineDocumentsList.value"
@close="d.closePreview" @close="d.closePreview"
/> />
@@ -108,6 +109,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 +174,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 +224,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
} }
}) })

View File

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

View File

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

View File

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

View File

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

View File

@@ -115,6 +115,7 @@
<th class="w-24">Aperçu</th> <th class="w-24">Aperçu</th>
<th>Nom</th> <th>Nom</th>
<th>Référence</th> <th>Référence</th>
<th>Description</th>
<th>Fournisseurs</th> <th>Fournisseurs</th>
<th>Type de pièce</th> <th>Type de pièce</th>
<th>Actions</th> <th>Actions</th>
@@ -130,6 +131,15 @@
</td> </td>
<td>{{ row.piece.name || 'Pièce sans nom' }}</td> <td>{{ row.piece.name || 'Pièce sans nom' }}</td>
<td>{{ row.piece.reference || '—' }}</td> <td>{{ row.piece.reference || '—' }}</td>
<td class="max-w-xs">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span>
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div>
</div>
<span v-else></span>
</td>
<td> <td>
<div <div
v-if="row.suppliers.visible.length" v-if="row.suppliers.visible.length"
@@ -171,6 +181,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"
@@ -207,6 +218,7 @@ 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()
@@ -289,7 +301,7 @@ const resolvePrimaryDocument = (piece: Record<string, any>) => {
return null return null
} }
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path) const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
const pdf = withPath.find((doc) => isPdfDocument(doc)) const pdf = withPath.find((doc) => isPdfDocument(doc))
if (pdf) { if (pdf) {

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview" @close="closePreview"
/> />
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
@@ -72,13 +73,26 @@
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
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -88,7 +102,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 +114,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 +132,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 +173,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 +238,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 +247,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 +265,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 +290,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 +308,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"
@@ -320,8 +334,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -366,6 +380,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 +473,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,6 +536,7 @@ 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()
@@ -556,6 +582,7 @@ const selectedTypeId = ref<string>('')
const pieceTypeDetails = ref<any | null>(null) const pieceTypeDetails = ref<any | null>(null)
const editionForm = reactive({ const editionForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -731,6 +758,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 &&
@@ -810,6 +838,7 @@ watch(
selectedTypeId.value = resolvedTypeId selectedTypeId.value = resolvedTypeId
editionForm.name = currentPiece.name || '' editionForm.name = currentPiece.name || ''
editionForm.description = currentPiece.description || ''
editionForm.reference = currentPiece.reference || '' editionForm.reference = currentPiece.reference || ''
editionForm.constructeurIds = uniqueConstructeurIds( editionForm.constructeurIds = uniqueConstructeurIds(
currentPiece, currentPiece,
@@ -882,6 +911,7 @@ const submitEdition = async () => {
const payload: Record<string, any> = { const payload: Record<string, any> = {
name: editionForm.name.trim(), name: editionForm.name.trim(),
description: editionForm.description.trim() || null,
constructeurIds, constructeurIds,
} }

View File

@@ -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,13 +45,26 @@
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
> >
</div> </div>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
@@ -61,7 +74,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 +86,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 +103,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 +144,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 +209,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 +218,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 +236,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 +261,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 +279,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,12 +342,14 @@ 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)
const submitting = ref(false) const submitting = ref(false)
const creationForm = reactive({ const creationForm = reactive({
name: '' as string, name: '' as string,
description: '' as string,
reference: '' as string, reference: '' as string,
constructeurIds: [] as string[], constructeurIds: [] as string[],
prix: '' as string, prix: '' as string,
@@ -478,6 +493,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 &&
@@ -488,6 +504,7 @@ const canSubmit = computed(() =>
const clearCreationForm = () => { const clearCreationForm = () => {
creationForm.name = '' creationForm.name = ''
creationForm.description = ''
creationForm.reference = '' creationForm.reference = ''
creationForm.constructeurIds = [] creationForm.constructeurIds = []
creationForm.prix = '' creationForm.prix = ''
@@ -511,6 +528,11 @@ const submitCreation = async () => {
typePieceId: selectedType.value.id, typePieceId: selectedType.value.id,
} }
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim() const reference = creationForm.reference.trim()
if (reference) { if (reference) {
payload.reference = reference payload.reference = reference

View File

@@ -153,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)"
@@ -179,6 +180,8 @@ 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',
})) }))
@@ -364,7 +367,7 @@ const resolvePrimaryDocument = (product: Record<string, any>) => {
return null return null
} }
const normalized = documents.filter((doc) => doc && typeof doc === 'object') const normalized = documents.filter((doc) => doc && typeof doc === 'object')
const withPath = normalized.filter((doc) => doc?.path) const withPath = normalized.filter((doc) => doc?.fileUrl || doc?.path)
if (!withPath.length) { if (!withPath.length) {
return normalized[0] ?? null return normalized[0] ?? null
} }

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="productDocuments"
@close="closePreview" @close="closePreview"
/> />
<main class="container mx-auto px-6 py-10"> <main class="container mx-auto px-6 py-10">
@@ -64,7 +65,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 +80,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 +91,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 +109,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 +149,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 +158,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 +176,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 +201,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 +219,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"
@@ -244,8 +245,8 @@
:class="documentThumbnailClass(document)" :class="documentThumbnailClass(document)"
> >
<img <img
v-if="isImageDocument(document) && document.path" v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.path" :src="document.fileUrl || document.path"
class="h-full w-full object-cover" class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`" :alt="`Aperçu de ${document.name}`"
> >
@@ -286,6 +287,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 +383,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,6 +436,7 @@ 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()
@@ -489,7 +502,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))

View File

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

View File

@@ -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">
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 () => {

View File

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

View File

@@ -3,6 +3,7 @@
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
:documents="siteDocuments"
@close="closePreview" @close="closePreview"
/> />
@@ -11,7 +12,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 +31,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 +51,7 @@
<SiteCreateModal <SiteCreateModal
:visible="showAddSiteModal" :visible="showAddSiteModal"
:site="newSite" :site="newSite"
:disabled="!canEdit"
@close="closeCreateModal" @close="closeCreateModal"
@submit="handleCreateSite" @submit="handleCreateSite"
/> />
@@ -64,6 +66,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 +86,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,

View File

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

View File

@@ -8,6 +8,26 @@
</p> </p>
</div> </div>
<!-- Locked: machines linked -->
<div v-else-if="type && hasMachines" class="my-8">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-6">
<h2 class="card-title text-2xl">
{{ type.name }}
</h2>
<NuxtLink to="/machine-skeleton" class="btn btn-outline">
Retour
</NuxtLink>
</div>
<div class="alert alert-warning">
<IconLucideTriangleAlert class="w-5 h-5" />
<span>Ce squelette ne peut pas être modifié car des machines y sont rattachées.</span>
</div>
</div>
</div>
</div>
<!-- Edit Form --> <!-- Edit Form -->
<div v-else-if="type" class="my-8"> <div v-else-if="type" class="my-8">
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
@@ -48,12 +68,14 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useMachineTypesApi } from '~/composables/useMachineTypesApi' 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'
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert'
const { canEdit } = usePermissions()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { getMachineTypeById, updateMachineType } = useMachineTypesApi() const { getMachineTypeById, updateMachineType } = useMachineTypesApi()
@@ -62,6 +84,10 @@ const { showSuccess, showError } = useToast()
const type = ref(null) const type = ref(null)
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const hasMachines = computed(() => {
const machines = type.value?.machines
return Array.isArray(machines) && machines.length > 0
})
// Données éditées du type // Données éditées du type
const editedType = ref({ const editedType = ref({
@@ -204,6 +230,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)

View File

@@ -19,26 +19,32 @@ export const formatSize = (size: number | null | undefined): string => {
return `${formatted.toFixed(1)} ${units[index]}` return `${formatted.toFixed(1)} ${units[index]}`
} }
const resolveUrl = (doc: any): string => doc?.fileUrl || doc?.path || ''
export const shouldInlinePdf = (doc: any): boolean => { export const shouldInlinePdf = (doc: any): boolean => {
if (!doc || !isPdfDocument(doc) || !doc.path) return false if (!doc || !isPdfDocument(doc)) return false
const url = resolveUrl(doc)
if (!url) return false
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) return false
return true return true
} }
export const appendPdfViewerParams = (src: string): string => { export const appendPdfViewerParams = (src: string): string => {
if (!src || src.startsWith('data:')) return src || '' if (!src) return ''
if (src.startsWith('data:')) return src
if (src.includes('#')) return `${src}&toolbar=0&navpanes=0` if (src.includes('#')) return `${src}&toolbar=0&navpanes=0`
return `${src}#toolbar=0&navpanes=0` return `${src}#toolbar=0&navpanes=0`
} }
export const documentPreviewSrc = (doc: any): string => { export const documentPreviewSrc = (doc: any): string => {
if (!doc?.path) return '' const url = resolveUrl(doc)
if (isPdfDocument(doc)) return appendPdfViewerParams(doc.path) if (!url) return ''
return doc.path if (isPdfDocument(doc)) return appendPdfViewerParams(url)
return url
} }
export const documentThumbnailClass = (doc: any): string => { export const documentThumbnailClass = (doc: any): string => {
if (shouldInlinePdf(doc) || (isImageDocument(doc) && doc?.path)) return 'h-24 w-20' if (shouldInlinePdf(doc) || (isImageDocument(doc) && resolveUrl(doc))) return 'h-24 w-20'
return 'h-16 w-16' return 'h-16 w-16'
} }
@@ -52,8 +58,14 @@ export const documentIcon = (doc: any): FileIconResult =>
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType }) getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
export const downloadDocument = (doc: any): void => { export const downloadDocument = (doc: any): void => {
if (!doc?.path) return // Prefer dedicated download endpoint
const target = String(doc.path) if (doc?.downloadUrl) {
window.open(doc.downloadUrl, '_blank')
return
}
// Fallback for legacy data: URIs during migration
const target = resolveUrl(doc)
if (!target) return
if (target.startsWith('data:')) { if (target.startsWith('data:')) {
const link = document.createElement('a') const link = document.createElement('a')
link.href = target link.href = target

View File

@@ -3,19 +3,19 @@ import { getFileIcon } from './fileIcons'
export const getPreviewType = (document) => { export const getPreviewType = (document) => {
if (!document) { return null } if (!document) { return null }
const mime = (document.mimeType || '').toLowerCase() const mime = (document.mimeType || '').toLowerCase()
const path = document.path || ''
const check = prefix => mime.startsWith(prefix) || path.startsWith(`data:${prefix}`) if (mime.startsWith('image/')) { return 'image' }
if (mime === 'application/pdf') { return 'pdf' }
if (check('image/')) { return 'image' } if (mime.startsWith('audio/')) { return 'audio' }
if (mime === 'application/pdf' || path.startsWith('data:application/pdf')) { return 'pdf' } if (mime.startsWith('video/')) { return 'video' }
if (check('audio/')) { return 'audio' } if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) { return 'text' }
if (check('video/')) { return 'video' }
if (check('text/') || mime.includes('json') || mime.includes('xml') || path.startsWith('data:application/json')) { return 'text' }
return null return null
} }
export const canPreviewDocument = (document = {}) => !!getPreviewType(document) export const canPreviewDocument = (document = {}) => {
if (!getPreviewType(document)) return false
return !!(document.fileUrl || document.path)
}
export const isImageDocument = (document = {}) => getPreviewType(document) === 'image' export const isImageDocument = (document = {}) => getPreviewType(document) === 'image'

35
e2e/auth.setup.ts Normal file
View 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 })
})

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

View File

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

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

View File

@@ -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
View 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'],
},
],
})