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,6 +27,17 @@
{{ $t('portal.ticketDetail') }} {{ $t('portal.ticketDetail') }}
</h2> </h2>
</div> </div>
<div class="flex items-center gap-2">
<!-- Edit button (only for open tickets submitted by current user) -->
<button
v-if="canEdit && !isEditing"
type="button"
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"
@click="startEdit"
>
<Icon name="mdi:pencil-outline" size="16" />
{{ $t('common.edit') }}
</button>
<button <button
type="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" 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"
@@ -36,9 +47,68 @@
</button> </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">
<!-- 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">
<label class="mb-1 block text-sm font-medium text-neutral-700">
{{ $t('clientTicket.description') }}
</label>
<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>
<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">
<button
type="button"
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
@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>
<!-- View mode -->
<template v-else>
<!-- Title --> <!-- Title -->
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3> <h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
@@ -111,6 +181,7 @@
<p class="mt-6 text-xs text-neutral-400"> <p class="mt-6 text-xs text-neutral-400">
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }} {{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
</p> </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 }
} }