Compare commits
7 Commits
e459da7c20
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e88ed5b8f2 | ||
|
|
546cc37a09 | ||
|
|
efd0fbe407 | ||
|
|
607f84fc3d | ||
|
|
a98ab8c275 | ||
|
|
e22463874c | ||
|
|
256039264e |
212
app/components/CommentSection.vue
Normal file
212
app/components/CommentSection.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<IconLucideMessageSquare class="w-5 h-5" />
|
||||||
|
Commentaires
|
||||||
|
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
||||||
|
{{ openComments.length }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
v-if="showResolved && resolvedComments.length"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs"
|
||||||
|
@click="showResolvedList = !showResolvedList"
|
||||||
|
>
|
||||||
|
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Formulaire d'ajout -->
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
v-model="newContent"
|
||||||
|
class="textarea textarea-bordered flex-1 text-sm"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Ajouter un commentaire..."
|
||||||
|
:disabled="submitting"
|
||||||
|
@keydown.ctrl.enter="handleSubmit"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm self-end"
|
||||||
|
:disabled="!newContent.trim() || submitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||||
|
<IconLucideSend v-else class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Liste des commentaires ouverts -->
|
||||||
|
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||||
|
<span class="loading loading-spinner loading-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
||||||
|
Aucun commentaire ouvert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="comment in openComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200 rounded-lg p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
|
<span>
|
||||||
|
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
||||||
|
</span>
|
||||||
|
<div v-if="canEdit" class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-xs gap-1"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleResolve(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideCheck class="w-3 h-3" />
|
||||||
|
Résoudre
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-xs text-error"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleDelete(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideTrash2 class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commentaires résolus -->
|
||||||
|
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
||||||
|
<div class="divider text-xs text-base-content/40">
|
||||||
|
Résolus
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="comment in resolvedComments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||||
|
>
|
||||||
|
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||||
|
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||||
|
<span v-if="comment.resolvedByName">
|
||||||
|
Résolu par {{ comment.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||||
|
import IconLucideSend from '~icons/lucide/send'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string
|
||||||
|
showResolved?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const newContent = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const loadingComments = ref(false)
|
||||||
|
const showResolvedList = ref(false)
|
||||||
|
|
||||||
|
const openComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'open'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const resolvedComments = computed(() =>
|
||||||
|
comments.value.filter(c => c.status === 'resolved'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loadingComments.value = true
|
||||||
|
const [openResult, resolvedResult] = await Promise.all([
|
||||||
|
fetchComments(props.entityType, props.entityId, 'open'),
|
||||||
|
props.showResolved
|
||||||
|
? fetchComments(props.entityType, props.entityId, 'resolved')
|
||||||
|
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
||||||
|
])
|
||||||
|
const open = openResult.success ? (openResult.data ?? []) : []
|
||||||
|
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
||||||
|
comments.value = [...open, ...resolved]
|
||||||
|
loadingComments.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const content = newContent.value.trim()
|
||||||
|
if (!content) return
|
||||||
|
submitting.value = true
|
||||||
|
const result = await createComment(
|
||||||
|
props.entityType,
|
||||||
|
props.entityId,
|
||||||
|
content,
|
||||||
|
props.entityName,
|
||||||
|
)
|
||||||
|
submitting.value = false
|
||||||
|
if (result.success) {
|
||||||
|
newContent.value = ''
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (commentId: string) => {
|
||||||
|
const result = await deleteComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.entityId) {
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"> • {{ 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>
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
@@ -193,6 +205,15 @@
|
|||||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<NuxtLink to="/comments" class="justify-between">
|
||||||
|
Commentaires
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -212,11 +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 { 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'
|
||||||
@@ -277,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é' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -291,6 +314,24 @@ 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 { 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 === '/') {
|
||||||
|
|||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -57,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}`"
|
||||||
>
|
>
|
||||||
@@ -116,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'
|
||||||
@@ -173,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -115,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' })
|
||||||
}
|
}
|
||||||
@@ -123,6 +129,7 @@ export function useApi() {
|
|||||||
apiCall,
|
apiCall,
|
||||||
get,
|
get,
|
||||||
post,
|
post,
|
||||||
|
postFormData,
|
||||||
patch,
|
patch,
|
||||||
put,
|
put,
|
||||||
delete: del,
|
delete: del,
|
||||||
|
|||||||
184
app/composables/useComments.ts
Normal file
184
app/composables/useComments.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useApi } from './useApi'
|
||||||
|
import { useToast } from './useToast'
|
||||||
|
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||||
|
|
||||||
|
export interface Comment {
|
||||||
|
id: string
|
||||||
|
content: string
|
||||||
|
entityType: string
|
||||||
|
entityId: string
|
||||||
|
entityName?: string | null
|
||||||
|
authorId: string
|
||||||
|
authorName: string
|
||||||
|
status: 'open' | 'resolved'
|
||||||
|
resolvedById?: string | null
|
||||||
|
resolvedByName?: string | null
|
||||||
|
resolvedAt?: string | null
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment | Comment[]
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CommentListResult {
|
||||||
|
success: boolean
|
||||||
|
data?: Comment[]
|
||||||
|
total?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComments() {
|
||||||
|
const { get, post, patch, delete: del } = useApi()
|
||||||
|
const { showSuccess, showError } = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const fetchComments = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
status: string = 'open',
|
||||||
|
): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
status,
|
||||||
|
'order[createdAt]': 'desc',
|
||||||
|
itemsPerPage: '200',
|
||||||
|
})
|
||||||
|
const result = await get(`/comments?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection<Comment>(result.data)
|
||||||
|
return { success: true, data: items }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchAllComments = async (options: {
|
||||||
|
status?: string
|
||||||
|
entityType?: string
|
||||||
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
} = {}): Promise<CommentListResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (options.status) params.set('status', options.status)
|
||||||
|
if (options.entityType) params.set('entityType', options.entityType)
|
||||||
|
params.set('order[createdAt]', 'desc')
|
||||||
|
params.set('itemsPerPage', String(options.itemsPerPage || 30))
|
||||||
|
params.set('page', String(options.page || 1))
|
||||||
|
|
||||||
|
const result = await get(`/comments?${params.toString()}`)
|
||||||
|
if (result.success) {
|
||||||
|
const items = extractCollection<Comment>(result.data)
|
||||||
|
const raw = result.data as Record<string, unknown> | null
|
||||||
|
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
|
||||||
|
return { success: true, data: items, total }
|
||||||
|
}
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createComment = async (
|
||||||
|
entityType: string,
|
||||||
|
entityId: string,
|
||||||
|
content: string,
|
||||||
|
entityName?: string,
|
||||||
|
): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = { entityType, entityId, content }
|
||||||
|
if (entityName) payload.entityName = entityName
|
||||||
|
const result = await post('/comments', payload)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire ajouté')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible d\'ajouter le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await patch(`/comments/${commentId}/resolve`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire résolu')
|
||||||
|
return { success: true, data: result.data as Comment }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de résoudre le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await del(`/comments/${commentId}`)
|
||||||
|
if (result.success) {
|
||||||
|
showSuccess('Commentaire supprimé')
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
if (result.error) showError(result.error)
|
||||||
|
return { success: false, error: result.error }
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as Error
|
||||||
|
showError('Impossible de supprimer le commentaire')
|
||||||
|
return { success: false, error: err.message }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||||
|
if (result.success && result.data) {
|
||||||
|
return result.data.count
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loading,
|
||||||
|
fetchComments,
|
||||||
|
fetchAllComments,
|
||||||
|
createComment,
|
||||||
|
resolveComment,
|
||||||
|
deleteComment,
|
||||||
|
fetchUnresolvedCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
331
app/pages/comments.vue
Normal file
331
app/pages/comments.vue
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<template>
|
||||||
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
||||||
|
<header>
|
||||||
|
<h1 class="text-3xl font-semibold text-base-content">
|
||||||
|
Commentaires
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body space-y-4">
|
||||||
|
<!-- Filtres -->
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-status"
|
||||||
|
>
|
||||||
|
Statut
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-status"
|
||||||
|
v-model="statusFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="open">
|
||||||
|
Ouverts
|
||||||
|
</option>
|
||||||
|
<option value="resolved">
|
||||||
|
Résolus
|
||||||
|
</option>
|
||||||
|
<option value="">
|
||||||
|
Tous
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-entity-type"
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-entity-type"
|
||||||
|
v-model="entityTypeFilter"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
Tous
|
||||||
|
</option>
|
||||||
|
<option value="machine">
|
||||||
|
Machine
|
||||||
|
</option>
|
||||||
|
<option value="piece">
|
||||||
|
Pièce
|
||||||
|
</option>
|
||||||
|
<option value="composant">
|
||||||
|
Composant
|
||||||
|
</option>
|
||||||
|
<option value="product">
|
||||||
|
Produit
|
||||||
|
</option>
|
||||||
|
<option value="piece_category">
|
||||||
|
Catégorie pièce
|
||||||
|
</option>
|
||||||
|
<option value="component_category">
|
||||||
|
Catégorie composant
|
||||||
|
</option>
|
||||||
|
<option value="product_category">
|
||||||
|
Catégorie produit
|
||||||
|
</option>
|
||||||
|
<option value="machine_skeleton">
|
||||||
|
Squelette machine
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||||
|
for="comment-per-page"
|
||||||
|
>
|
||||||
|
Par page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="comment-per-page"
|
||||||
|
v-model.number="itemsPerPage"
|
||||||
|
class="select select-bordered select-sm"
|
||||||
|
@change="handleFilterChange"
|
||||||
|
>
|
||||||
|
<option :value="20">
|
||||||
|
20
|
||||||
|
</option>
|
||||||
|
<option :value="50">
|
||||||
|
50
|
||||||
|
</option>
|
||||||
|
<option :value="100">
|
||||||
|
100
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-base-content/50 lg:text-right">
|
||||||
|
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loadingList" class="flex justify-center py-8">
|
||||||
|
<span class="loading loading-spinner" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty states -->
|
||||||
|
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
||||||
|
Aucun commentaire trouvé.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Table -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm md:table-md">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Contenu</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Item</th>
|
||||||
|
<th>Auteur</th>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Statut</th>
|
||||||
|
<th v-if="canEdit">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="comment in comments"
|
||||||
|
:key="comment.id"
|
||||||
|
class="hover"
|
||||||
|
>
|
||||||
|
<td class="max-w-xs">
|
||||||
|
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge-outline badge-sm">
|
||||||
|
{{ entityTypeLabel(comment.entityType) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NuxtLink
|
||||||
|
v-if="getEntityRoute(comment)"
|
||||||
|
:to="getEntityRoute(comment)!"
|
||||||
|
class="link link-primary text-sm font-medium"
|
||||||
|
>
|
||||||
|
{{ comment.entityName || comment.entityId }}
|
||||||
|
</NuxtLink>
|
||||||
|
<span v-else class="text-sm">
|
||||||
|
{{ comment.entityName || comment.entityId }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-sm">
|
||||||
|
{{ comment.authorName }}
|
||||||
|
</td>
|
||||||
|
<td class="text-sm whitespace-nowrap">
|
||||||
|
{{ formatCommentDate(comment.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="badge badge-sm"
|
||||||
|
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
||||||
|
>
|
||||||
|
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="canEdit" @click.stop>
|
||||||
|
<button
|
||||||
|
v-if="comment.status === 'open'"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-xs gap-1"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="handleResolve(comment.id)"
|
||||||
|
>
|
||||||
|
<IconLucideCheck class="w-3 h-3" />
|
||||||
|
Résoudre
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-xs text-base-content/50">
|
||||||
|
{{ comment.resolvedByName }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:disabled="page <= 1"
|
||||||
|
@click="goToPage(page - 1)"
|
||||||
|
>
|
||||||
|
Précédent
|
||||||
|
</button>
|
||||||
|
<span class="flex items-center text-sm text-base-content/70">
|
||||||
|
Page {{ page }} / {{ totalPages }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
:disabled="page >= totalPages"
|
||||||
|
@click="goToPage(page + 1)"
|
||||||
|
>
|
||||||
|
Suivant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useComments, type Comment } from '~/composables/useComments'
|
||||||
|
import { usePermissions } from '~/composables/usePermissions'
|
||||||
|
import IconLucideCheck from '~icons/lucide/check'
|
||||||
|
|
||||||
|
const { canEdit } = usePermissions()
|
||||||
|
const {
|
||||||
|
loading,
|
||||||
|
fetchAllComments,
|
||||||
|
resolveComment,
|
||||||
|
} = useComments()
|
||||||
|
|
||||||
|
const comments = ref<Comment[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const itemsPerPage = ref(20)
|
||||||
|
const statusFilter = ref('open')
|
||||||
|
const entityTypeFilter = ref('')
|
||||||
|
const loadingList = ref(false)
|
||||||
|
|
||||||
|
const totalPages = computed(() =>
|
||||||
|
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
||||||
|
machine: 'Machine',
|
||||||
|
piece: 'Pièce',
|
||||||
|
composant: 'Composant',
|
||||||
|
product: 'Produit',
|
||||||
|
piece_category: 'Cat. pièce',
|
||||||
|
component_category: 'Cat. composant',
|
||||||
|
product_category: 'Cat. produit',
|
||||||
|
machine_skeleton: 'Squelette',
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityTypeLabel = (type: string): string =>
|
||||||
|
ENTITY_TYPE_LABELS[type] ?? type
|
||||||
|
|
||||||
|
const formatCommentDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (Number.isNaN(date.getTime())) return '—'
|
||||||
|
return new Intl.DateTimeFormat('fr-FR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadComments = async () => {
|
||||||
|
loadingList.value = true
|
||||||
|
const result = await fetchAllComments({
|
||||||
|
status: statusFilter.value || undefined,
|
||||||
|
entityType: entityTypeFilter.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
itemsPerPage: itemsPerPage.value,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
comments.value = result.data ?? []
|
||||||
|
total.value = result.total ?? 0
|
||||||
|
}
|
||||||
|
loadingList.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFilterChange = () => {
|
||||||
|
page.value = 1
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToPage = (p: number) => {
|
||||||
|
page.value = p
|
||||||
|
loadComments()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResolve = async (commentId: string) => {
|
||||||
|
const result = await resolveComment(commentId)
|
||||||
|
if (result.success) {
|
||||||
|
await loadComments()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
|
||||||
|
machine: (id: string) => `/machine/${id}`,
|
||||||
|
piece: (id: string) => `/pieces/${id}/edit`,
|
||||||
|
composant: (id: string) => `/component/${id}/edit`,
|
||||||
|
product: (id: string) => `/product/${id}/edit`,
|
||||||
|
piece_category: (id: string) => `/piece-category/${id}/edit`,
|
||||||
|
component_category: (id: string) => `/component-category/${id}/edit`,
|
||||||
|
product_category: (id: string) => `/product-category/${id}/edit`,
|
||||||
|
machine_skeleton: (id: string) => `/type/${id}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEntityRoute = (comment: Comment): string | null => {
|
||||||
|
const builder = ENTITY_ROUTE_MAP[comment.entityType]
|
||||||
|
return builder ? builder(comment.entityId) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadComments()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -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"
|
||||||
@@ -269,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
|
||||||
|
|||||||
@@ -35,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -79,6 +80,19 @@
|
|||||||
</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">
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
@@ -512,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>
|
||||||
@@ -615,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,
|
||||||
@@ -795,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,
|
||||||
@@ -835,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()
|
||||||
|
|||||||
@@ -52,6 +52,19 @@
|
|||||||
</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">
|
||||||
@@ -408,6 +421,7 @@ 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,
|
||||||
@@ -889,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 = ''
|
||||||
@@ -906,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
|
||||||
|
|||||||
@@ -195,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
|
||||||
|
|||||||
@@ -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è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é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 été ajouté.
|
||||||
</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 à 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é à</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 · {{ doc.site.name }}</span>
|
||||||
<span v-if="document.site">Site · {{ document.site.name }}</span>
|
<span v-else-if="doc.machine">Machine · {{ doc.machine.name }}</span>
|
||||||
<span v-else-if="document.machine">Machine · {{ document.machine.name }}</span>
|
<span v-else-if="doc.composant">Composant · {{ doc.composant.name }}</span>
|
||||||
<span v-else-if="document.composant">Composant · {{ document.composant.name }}</span>
|
<span v-else-if="doc.piece">Pièce · {{ doc.piece.name }}</span>
|
||||||
<span v-else-if="document.piece">Pièce · {{ document.piece.name }}</span>
|
<span v-else-if="doc.product">Produit · {{ doc.product.name }}</span>
|
||||||
<span v-else class="text-gray-400">Non défini</span>
|
<span v-else class="text-gray-400">Non dé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élé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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -35,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -291,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) {
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -79,6 +80,19 @@
|
|||||||
</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">
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
@@ -459,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>
|
||||||
@@ -558,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,
|
||||||
@@ -813,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,
|
||||||
@@ -885,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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,19 @@
|
|||||||
</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">
|
||||||
@@ -336,6 +349,7 @@ 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,
|
||||||
@@ -490,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 = ''
|
||||||
@@ -513,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
|
||||||
|
|||||||
@@ -367,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -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}`"
|
||||||
>
|
>
|
||||||
@@ -382,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>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<DocumentPreviewModal
|
<DocumentPreviewModal
|
||||||
:document="previewDocument"
|
:document="previewDocument"
|
||||||
:visible="previewVisible"
|
:visible="previewVisible"
|
||||||
|
:documents="siteDocuments"
|
||||||
@close="closePreview"
|
@close="closePreview"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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,11 +68,12 @@
|
|||||||
</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 { canEdit } = usePermissions()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -63,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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
17
package-lock.json
generated
17
package-lock.json
generated
@@ -5550,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": {
|
||||||
@@ -5847,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",
|
||||||
|
|||||||
Reference in New Issue
Block a user