feat(portal) : allow client to edit own tickets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 20:41:25 +01:00
parent d2f6d84d03
commit ffe4a0117c
3 changed files with 213 additions and 72 deletions

View File

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

View File

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

View File

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