feat(portal) : add drag & drop status change on client ticket kanban
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -44,11 +44,21 @@
|
||||
{{ col.tickets.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="min-h-[60px] space-y-2 rounded-lg border-2 border-transparent p-1 transition-colors"
|
||||
:class="dragOverStatus === col.status ? 'border-primary-300 bg-primary-50/50' : ''"
|
||||
@dragover.prevent="onDragOver(col.status)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDrop(col.status)"
|
||||
>
|
||||
<div
|
||||
v-for="ticket in col.tickets"
|
||||
:key="ticket.id"
|
||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
||||
:class="isAdmin ? 'cursor-grab active:cursor-grabbing' : ''"
|
||||
:draggable="isAdmin"
|
||||
@dragstart="onDragStart(ticket)"
|
||||
@dragend="onDragEnd"
|
||||
@click="openDetail(ticket)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -60,7 +70,7 @@
|
||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<h4 class="mt-1.5 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</h4>
|
||||
<h4 class="mt-1.5 text-sm font-semibold leading-snug text-neutral-900">{{ ticket.title }}</h4>
|
||||
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||
</div>
|
||||
<p
|
||||
@@ -79,12 +89,47 @@
|
||||
:ticket="selectedTicket"
|
||||
@refresh="loadTickets"
|
||||
/>
|
||||
|
||||
<!-- Reject comment modal -->
|
||||
<Teleport v-if="rejectModalOpen" to="body">
|
||||
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" @click="cancelReject" />
|
||||
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
||||
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
||||
<p class="mt-2 text-sm text-neutral-600">{{ $t('clientTicket.rejectionRequired') }}</p>
|
||||
<textarea
|
||||
v-model="rejectComment"
|
||||
rows="3"
|
||||
class="mt-3 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.rejectComment')"
|
||||
/>
|
||||
<div class="mt-4 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 hover:bg-neutral-50"
|
||||
@click="cancelReject"
|
||||
>
|
||||
{{ $t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
:disabled="!rejectComment.trim()"
|
||||
@click="confirmReject"
|
||||
>
|
||||
{{ $t('clientTicket.status.rejected') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ClientTicket } from '~/services/dto/client-ticket'
|
||||
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
||||
import { useClientTicketService } from '~/services/client-tickets'
|
||||
import { useProjectService } from '~/services/projects'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'portal',
|
||||
@@ -97,26 +142,20 @@ const projectId = computed(() => Number(route.params.id))
|
||||
useHead({ title: t('portal.title') })
|
||||
|
||||
const clientTicketService = useClientTicketService()
|
||||
const projectService = useProjectService()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tickets = ref<ClientTicket[]>([])
|
||||
const projectName = ref('')
|
||||
const isLoading = ref(true)
|
||||
const detailOpen = ref(false)
|
||||
const selectedTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
const projectName = computed(() => {
|
||||
const me = auth.user as any
|
||||
if (me?.allowedProjects) {
|
||||
const project = me.allowedProjects.find((p: any) => p.id === projectId.value)
|
||||
return project?.name ?? ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') ?? false)
|
||||
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
||||
|
||||
const statuses = ['new', 'in_progress', 'done', 'rejected'] as const
|
||||
const allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
||||
|
||||
function statusDotClass(status: string): string {
|
||||
switch (status) {
|
||||
@@ -128,28 +167,118 @@ function statusDotClass(status: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const columns = computed(() => statuses.map(status => ({
|
||||
const columns = computed(() => allStatuses.map(status => ({
|
||||
status,
|
||||
label: t(`clientTicket.status.${status}`),
|
||||
dotClass: statusDotClass(status),
|
||||
tickets: tickets.value.filter(tk => tk.status === status),
|
||||
})))
|
||||
|
||||
// Drag & drop (admin only)
|
||||
const draggedTicket = ref<ClientTicket | null>(null)
|
||||
const dragOverStatus = ref<ClientTicketStatus | null>(null)
|
||||
|
||||
function onDragStart(ticket: ClientTicket) {
|
||||
draggedTicket.value = ticket
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
draggedTicket.value = null
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
function onDragOver(status: ClientTicketStatus) {
|
||||
if (!draggedTicket.value) return
|
||||
dragOverStatus.value = status
|
||||
}
|
||||
|
||||
function onDragLeave() {
|
||||
dragOverStatus.value = null
|
||||
}
|
||||
|
||||
async function onDrop(newStatus: ClientTicketStatus) {
|
||||
dragOverStatus.value = null
|
||||
const ticket = draggedTicket.value
|
||||
draggedTicket.value = null
|
||||
|
||||
if (!ticket || ticket.status === newStatus) return
|
||||
|
||||
// Rejected requires a comment
|
||||
if (newStatus === 'rejected') {
|
||||
pendingRejectTicket.value = ticket
|
||||
rejectComment.value = ''
|
||||
rejectModalOpen.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = newStatus
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, { status: newStatus })
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
}
|
||||
|
||||
// Reject modal
|
||||
const rejectModalOpen = ref(false)
|
||||
const rejectComment = ref('')
|
||||
const pendingRejectTicket = ref<ClientTicket | null>(null)
|
||||
|
||||
function cancelReject() {
|
||||
rejectModalOpen.value = false
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
async function confirmReject() {
|
||||
const ticket = pendingRejectTicket.value
|
||||
if (!ticket || !rejectComment.value.trim()) return
|
||||
|
||||
const oldStatus = ticket.status
|
||||
ticket.status = 'rejected'
|
||||
rejectModalOpen.value = false
|
||||
|
||||
try {
|
||||
await clientTicketService.updateStatus(ticket.id, {
|
||||
status: 'rejected',
|
||||
statusComment: rejectComment.value.trim(),
|
||||
})
|
||||
await loadTickets()
|
||||
} catch {
|
||||
ticket.status = oldStatus
|
||||
}
|
||||
|
||||
pendingRejectTicket.value = null
|
||||
rejectComment.value = ''
|
||||
}
|
||||
|
||||
function openDetail(ticket: ClientTicket) {
|
||||
selectedTicket.value = ticket
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
async function loadData() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
const [ticketList, project] = await Promise.all([
|
||||
clientTicketService.getAll({ project: projectId.value }),
|
||||
projectService.getById(projectId.value),
|
||||
])
|
||||
tickets.value = ticketList
|
||||
projectName.value = project.name
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTickets() {
|
||||
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTickets()
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<ProjectClientTickets
|
||||
v-if="project"
|
||||
:project-id="projectId"
|
||||
:project-name="project.name"
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 rounded-md bg-primary-500 px-3 py-2 text-xs font-semibold text-white hover:bg-secondary-500 sm:px-4 sm:text-sm"
|
||||
@click="openTaskCreate"
|
||||
|
||||
Reference in New Issue
Block a user