feat(portal) : allow client to edit own tickets
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,90 +27,161 @@
|
|||||||
{{ $t('portal.ticketDetail') }}
|
{{ $t('portal.ticketDetail') }}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-2">
|
||||||
type="button"
|
<!-- Edit button (only for open tickets submitted by current user) -->
|
||||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
<button
|
||||||
@click="close"
|
v-if="canEdit && !isEditing"
|
||||||
>
|
type="button"
|
||||||
<Icon name="mdi:close" size="20" />
|
class="flex h-8 items-center gap-1.5 rounded-lg px-3 text-sm font-medium text-primary-500 transition-colors hover:bg-primary-50"
|
||||||
</button>
|
@click="startEdit"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:pencil-outline" size="16" />
|
||||||
|
{{ $t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="20" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body -->
|
||||||
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
|
||||||
<!-- Title -->
|
|
||||||
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
|
||||||
|
|
||||||
<!-- Badges -->
|
<!-- Edit mode -->
|
||||||
<div class="mt-3 flex items-center gap-2">
|
<template v-if="isEditing">
|
||||||
<span
|
<div>
|
||||||
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
:class="typeBadgeClass(ticket.type)"
|
{{ $t('clientTicket.fields.title') }}
|
||||||
>
|
</label>
|
||||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
<input
|
||||||
</span>
|
v-model="editForm.title"
|
||||||
<span
|
type="text"
|
||||||
class="rounded-full px-3 py-1 text-xs font-semibold"
|
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"
|
||||||
:class="statusBadgeClass(ticket.status)"
|
/>
|
||||||
>
|
</div>
|
||||||
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description -->
|
<div class="mt-4">
|
||||||
<div class="mt-4">
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
{{ $t('clientTicket.description') }}
|
||||||
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
</label>
|
||||||
</div>
|
<textarea
|
||||||
|
v-model="editForm.description"
|
||||||
|
rows="5"
|
||||||
|
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>
|
||||||
|
|
||||||
<!-- URL (if bug) -->
|
<div v-if="ticket.type === 'bug'" class="mt-4">
|
||||||
<div v-if="ticket.url" class="mt-4">
|
<label class="mb-1 block text-sm font-medium text-neutral-700">
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
{{ $t('clientTicket.fields.url') }}
|
||||||
<a
|
</label>
|
||||||
:href="ticket.url"
|
<input
|
||||||
target="_blank"
|
v-model="editForm.url"
|
||||||
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
|
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"
|
||||||
{{ ticket.url }}
|
:placeholder="$t('clientTicket.fields.urlPlaceholder')"
|
||||||
</a>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status comment -->
|
<div class="mt-6 flex justify-end gap-3">
|
||||||
<div v-if="ticket.statusComment" class="mt-4">
|
<button
|
||||||
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
|
type="button"
|
||||||
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
||||||
</div>
|
@click="cancelEdit"
|
||||||
|
>
|
||||||
|
{{ $t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="isSaving"
|
||||||
|
@click="saveEdit"
|
||||||
|
>
|
||||||
|
{{ $t('common.save') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Documents -->
|
<!-- View mode -->
|
||||||
<TaskDocumentList
|
<template v-else>
|
||||||
v-if="localDocuments.length"
|
<!-- Title -->
|
||||||
:documents="localDocuments"
|
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
|
||||||
:is-admin="false"
|
|
||||||
@preview="openPreview"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Document preview -->
|
<!-- Badges -->
|
||||||
<TaskDocumentPreview
|
<div class="mt-3 flex items-center gap-2">
|
||||||
:document="previewDoc"
|
<span
|
||||||
:has-prev="previewIndex > 0"
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
||||||
:has-next="previewIndex < localDocuments.length - 1"
|
:class="typeBadgeClass(ticket.type)"
|
||||||
@close="previewDoc = null"
|
>
|
||||||
@prev="prevPreview"
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||||
@next="nextPreview"
|
</span>
|
||||||
/>
|
<span
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-semibold"
|
||||||
|
:class="statusBadgeClass(ticket.status)"
|
||||||
|
>
|
||||||
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Upload zone -->
|
<!-- Description -->
|
||||||
<TaskDocumentUpload
|
<div class="mt-4">
|
||||||
v-if="ticket"
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
|
||||||
:client-ticket-id="ticket.id"
|
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
|
||||||
@uploaded="refreshDocuments"
|
</div>
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Date -->
|
<!-- URL (if bug) -->
|
||||||
<p class="mt-6 text-xs text-neutral-400">
|
<div v-if="ticket.url" class="mt-4">
|
||||||
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
|
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
|
||||||
</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="false"
|
||||||
|
@preview="openPreview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -122,6 +193,7 @@
|
|||||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||||
import type { TaskDocument } from '~/services/dto/task-document'
|
import type { TaskDocument } from '~/services/dto/task-document'
|
||||||
import { useTaskDocumentService } from '~/services/task-documents'
|
import { useTaskDocumentService } from '~/services/task-documents'
|
||||||
|
import { useClientTicketService } from '~/services/client-tickets'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -130,6 +202,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'refresh'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
@@ -138,12 +211,72 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
isEditing.value = false
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
const { getByTicket } = useTaskDocumentService()
|
const { getByTicket } = useTaskDocumentService()
|
||||||
|
const clientTicketService = useClientTicketService()
|
||||||
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
|
||||||
|
|
||||||
|
// Edit mode
|
||||||
|
const isEditing = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const editForm = reactive({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
url: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
if (!props.ticket) return false
|
||||||
|
const status = props.ticket.status
|
||||||
|
if (status === 'done' || status === 'rejected') return false
|
||||||
|
// Check if current user submitted the ticket
|
||||||
|
const userId = auth.user?.id
|
||||||
|
if (!userId) return false
|
||||||
|
const submittedByIri = props.ticket.submittedBy
|
||||||
|
if (!submittedByIri) return false
|
||||||
|
return submittedByIri === `/api/users/${userId}`
|
||||||
|
})
|
||||||
|
|
||||||
|
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 any)
|
||||||
|
isEditing.value = false
|
||||||
|
emit('refresh')
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset edit mode when ticket changes
|
||||||
|
watch(() => props.ticket?.id, () => {
|
||||||
|
isEditing.value = false
|
||||||
|
})
|
||||||
|
|
||||||
async function refreshDocuments() {
|
async function refreshDocuments() {
|
||||||
if (!props.ticket) return
|
if (!props.ticket) return
|
||||||
localDocuments.value = await getByTicket(props.ticket.id)
|
localDocuments.value = await getByTicket(props.ticket.id)
|
||||||
|
|||||||
@@ -166,6 +166,8 @@
|
|||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
"save": "Enregistrer",
|
||||||
|
"edit": "Modifier",
|
||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"dateFilter": "Date",
|
"dateFilter": "Date",
|
||||||
"today": "Aujourd'hui",
|
"today": "Aujourd'hui",
|
||||||
|
|||||||
@@ -30,11 +30,17 @@ export function useClientTicketService() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function update(id: number, data: Partial<ClientTicketWrite>): Promise<ClientTicket> {
|
||||||
|
return api.patch<ClientTicket>(`/client_tickets/${id}`, data as Record<string, unknown>, {
|
||||||
|
toastSuccessKey: 'clientTicket.statusUpdated',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function remove(id: number): Promise<void> {
|
async function remove(id: number): Promise<void> {
|
||||||
await api.delete(`/client_tickets/${id}`, {}, {
|
await api.delete(`/client_tickets/${id}`, {}, {
|
||||||
toastSuccessKey: 'clientTicket.deleted',
|
toastSuccessKey: 'clientTicket.deleted',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return { getAll, getById, create, updateStatus, remove }
|
return { getAll, getById, create, update, updateStatus, remove }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user