Remplace les éditeurs markdown locaux et les textareas description par <MalioInputRichText> (TipTap v3 + StarterKit + tiptap-markdown) du paquet @malio/layer-ui. Sites migrés : - TaskModal (description tâche) - TaskGroupDrawer (description groupe de tâches) - TimeEntryDrawer (description time entry) - ClientTicketDetailModal (édition + lecture seule) - ProjectClientTickets (panneau admin lecture seule) - new-ticket (formulaire portail client) - client-tickets (vue admin lecture seule) Stockage en BDD inchangé : le markdown existant est parsé à l'ouverture, le composant émet du HTML par défaut sur les sauvegardes (migration lazy au fil des éditions). Bumpe @malio/layer-ui de ^1.2.3 à ^1.4.7 et ajoute les dépendances TipTap utilisées par le composant. Co-Authored-By: RuFlo <ruv@ruv.net>
354 lines
14 KiB
Vue
354 lines
14 KiB
Vue
<template>
|
|
<Teleport v-if="isOpen" to="body">
|
|
<Transition name="ticket-modal" appear>
|
|
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="close"
|
|
/>
|
|
|
|
<!-- Modal -->
|
|
<div
|
|
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
|
|
style="max-height: min(90vh, 900px)"
|
|
>
|
|
<!-- Header -->
|
|
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<span
|
|
v-if="ticket"
|
|
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
|
|
>
|
|
CT-{{ String(ticket.number).padStart(3, '0') }}
|
|
</span>
|
|
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
|
|
{{ $t('portal.ticketDetail') }}
|
|
</h2>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<!-- Edit button (only for open tickets submitted by current user) -->
|
|
<MalioButton
|
|
v-if="canEdit && !isEditing"
|
|
variant="tertiary"
|
|
icon-name="mdi:pencil-outline"
|
|
icon-position="left"
|
|
button-class="w-auto px-3"
|
|
:label="$t('common.edit')"
|
|
@click="startEdit"
|
|
/>
|
|
<MalioButtonIcon
|
|
icon="mdi:close"
|
|
aria-label="Fermer"
|
|
variant="ghost"
|
|
icon-size="20"
|
|
@click="close"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Body -->
|
|
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
|
|
|
<!-- Edit mode -->
|
|
<template v-if="isEditing">
|
|
<div>
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
|
{{ $t('clientTicket.fields.title') }}
|
|
</label>
|
|
<input
|
|
v-model="editForm.title"
|
|
type="text"
|
|
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
/>
|
|
</div>
|
|
|
|
<div class="mt-4">
|
|
<MalioInputRichText
|
|
v-model="editForm.description"
|
|
:label="$t('clientTicket.description')"
|
|
min-height="180px"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="ticket.type === 'bug'" class="mt-4">
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
|
{{ $t('clientTicket.fields.url') }}
|
|
</label>
|
|
<input
|
|
v-model="editForm.url"
|
|
type="url"
|
|
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
|
|
/>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<MalioButton
|
|
variant="tertiary"
|
|
:label="$t('common.cancel')"
|
|
button-class="w-auto px-4"
|
|
@click="cancelEdit"
|
|
/>
|
|
<MalioButton
|
|
:label="$t('common.save')"
|
|
button-class="w-auto px-6"
|
|
:disabled="isSaving"
|
|
@click="saveEdit"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- View mode -->
|
|
<template v-else>
|
|
<!-- Title -->
|
|
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
|
|
|
<!-- Badges -->
|
|
<div class="mt-3 flex items-center gap-2">
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:class="typeBadgeClass(ticket.type)"
|
|
>
|
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
|
</span>
|
|
<span
|
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
|
:class="statusBadgeClass(ticket.status)"
|
|
>
|
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div class="mt-4">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
|
<MalioInputRichText
|
|
v-if="ticket.description"
|
|
:model-value="ticket.description"
|
|
:editable="false"
|
|
group-class="mt-1"
|
|
/>
|
|
<p v-else class="mt-1 text-sm italic text-neutral-400">—</p>
|
|
</div>
|
|
|
|
<!-- URL (if bug) -->
|
|
<div v-if="ticket.url" class="mt-4">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
|
<a
|
|
:href="ticket.url"
|
|
target="_blank"
|
|
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
|
>
|
|
{{ ticket.url }}
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Status comment -->
|
|
<div v-if="ticket.statusComment" class="mt-4">
|
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
|
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
|
</div>
|
|
|
|
<!-- Documents -->
|
|
<TaskDocumentList
|
|
v-if="localDocuments.length"
|
|
:documents="localDocuments"
|
|
:is-admin="canEdit"
|
|
@preview="openPreview"
|
|
@delete="handleDeleteDocument"
|
|
/>
|
|
|
|
<!-- Document preview -->
|
|
<TaskDocumentPreview
|
|
:document="previewDoc"
|
|
:has-prev="previewIndex > 0"
|
|
:has-next="previewIndex < localDocuments.length - 1"
|
|
@close="previewDoc = null"
|
|
@prev="prevPreview"
|
|
@next="nextPreview"
|
|
/>
|
|
|
|
<!-- Upload zone -->
|
|
<TaskDocumentUpload
|
|
v-if="ticket"
|
|
:client-ticket-id="ticket.id"
|
|
@uploaded="refreshDocuments"
|
|
/>
|
|
|
|
<!-- Date -->
|
|
<p class="mt-6 text-xs text-neutral-400">
|
|
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
|
</p>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicket, ClientTicketWrite } from '~/services/dto/client-ticket'
|
|
import type { TaskDocument } from '~/services/dto/task-document'
|
|
import { useTaskDocumentService } from '~/services/task-documents'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
ticket: ClientTicket | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', value: boolean): void
|
|
(e: 'refresh'): void
|
|
}>()
|
|
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (v) => emit('update:modelValue', v),
|
|
})
|
|
|
|
function close() {
|
|
isEditing.value = false
|
|
isOpen.value = false
|
|
}
|
|
|
|
const auth = useAuthStore()
|
|
const { getByTicket, remove: removeDocument } = useTaskDocumentService()
|
|
const clientTicketService = useClientTicketService()
|
|
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
|
|
|
// Edit mode
|
|
const isEditing = ref(false)
|
|
const isSaving = ref(false)
|
|
const editForm = reactive({
|
|
title: '',
|
|
description: '',
|
|
url: '',
|
|
})
|
|
|
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
|
|
|
const canEdit = computed(() => {
|
|
if (!props.ticket) return false
|
|
if (isAdmin.value) return true
|
|
const status = props.ticket.status
|
|
if (status === 'done' || status === 'rejected') return false
|
|
const userId = auth.user?.id
|
|
if (!userId) return false
|
|
const sub = props.ticket.submittedBy
|
|
if (!sub) return false
|
|
// submittedBy can be an IRI string or an embedded object
|
|
if (typeof sub === 'string') return sub === `/api/users/${userId}`
|
|
if (typeof sub === 'object' && 'id' in sub) return (sub as { id: number }).id === userId
|
|
return false
|
|
})
|
|
|
|
function startEdit() {
|
|
if (!props.ticket) return
|
|
editForm.title = props.ticket.title
|
|
editForm.description = props.ticket.description
|
|
editForm.url = props.ticket.url ?? ''
|
|
isEditing.value = true
|
|
}
|
|
|
|
function cancelEdit() {
|
|
isEditing.value = false
|
|
}
|
|
|
|
async function saveEdit() {
|
|
if (!props.ticket) return
|
|
isSaving.value = true
|
|
try {
|
|
const data: Record<string, unknown> = {
|
|
title: editForm.title,
|
|
description: editForm.description,
|
|
}
|
|
if (props.ticket.type === 'bug') {
|
|
data.url = editForm.url || null
|
|
}
|
|
await clientTicketService.update(props.ticket.id, data as Partial<ClientTicketWrite>)
|
|
isEditing.value = false
|
|
emit('refresh')
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
// Reset edit mode when ticket changes
|
|
watch(() => props.ticket?.id, () => {
|
|
isEditing.value = false
|
|
})
|
|
|
|
async function handleDeleteDocument(doc: TaskDocument) {
|
|
await removeDocument(doc.id)
|
|
await refreshDocuments()
|
|
}
|
|
|
|
async function refreshDocuments() {
|
|
if (!props.ticket) return
|
|
localDocuments.value = await getByTicket(props.ticket.id)
|
|
}
|
|
|
|
// Document list (local copy to allow refresh)
|
|
const localDocuments = ref<TaskDocument[]>([])
|
|
|
|
watch(() => props.ticket?.documents, (docs) => {
|
|
localDocuments.value = docs ? [...docs] : []
|
|
}, { immediate: true })
|
|
|
|
// Document preview
|
|
const previewDoc = ref<TaskDocument | null>(null)
|
|
|
|
const previewIndex = computed(() => {
|
|
if (!previewDoc.value) return -1
|
|
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
|
|
})
|
|
|
|
function openPreview(doc: TaskDocument) {
|
|
previewDoc.value = doc
|
|
}
|
|
|
|
function prevPreview() {
|
|
if (previewIndex.value > 0) {
|
|
previewDoc.value = localDocuments.value[previewIndex.value - 1]
|
|
}
|
|
}
|
|
|
|
function nextPreview() {
|
|
if (previewIndex.value < localDocuments.value.length - 1) {
|
|
previewDoc.value = localDocuments.value[previewIndex.value + 1]
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ticket-modal-enter-active,
|
|
.ticket-modal-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.ticket-modal-enter-active > div:last-child,
|
|
.ticket-modal-leave-active > div:last-child {
|
|
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
|
|
}
|
|
|
|
.ticket-modal-enter-from,
|
|
.ticket-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.ticket-modal-enter-from > div:last-child {
|
|
transform: scale(0.95) translateY(8px);
|
|
opacity: 0;
|
|
}
|
|
|
|
.ticket-modal-leave-to > div:last-child {
|
|
transform: scale(0.97);
|
|
opacity: 0;
|
|
}
|
|
</style>
|