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 }}
|
{{ col.tickets.length }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
v-for="ticket in col.tickets"
|
v-for="ticket in col.tickets"
|
||||||
:key="ticket.id"
|
:key="ticket.id"
|
||||||
class="cursor-pointer rounded-lg border border-neutral-200 bg-white p-3 shadow-sm transition hover:shadow-md"
|
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)"
|
@click="openDetail(ticket)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -60,7 +70,7 @@
|
|||||||
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<p class="mt-1.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
@@ -79,12 +89,47 @@
|
|||||||
:ticket="selectedTicket"
|
:ticket="selectedTicket"
|
||||||
@refresh="loadTickets"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useClientTicketService } from '~/services/client-tickets'
|
||||||
|
import { useProjectService } from '~/services/projects'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'portal',
|
layout: 'portal',
|
||||||
@@ -97,26 +142,20 @@ const projectId = computed(() => Number(route.params.id))
|
|||||||
useHead({ title: t('portal.title') })
|
useHead({ title: t('portal.title') })
|
||||||
|
|
||||||
const clientTicketService = useClientTicketService()
|
const clientTicketService = useClientTicketService()
|
||||||
|
const projectService = useProjectService()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const tickets = ref<ClientTicket[]>([])
|
const tickets = ref<ClientTicket[]>([])
|
||||||
|
const projectName = ref('')
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const detailOpen = ref(false)
|
const detailOpen = ref(false)
|
||||||
const selectedTicket = ref<ClientTicket | null>(null)
|
const selectedTicket = ref<ClientTicket | null>(null)
|
||||||
|
|
||||||
const projectName = computed(() => {
|
const isClient = computed(() => auth.user?.roles?.includes('ROLE_CLIENT') && !auth.user?.roles?.includes('ROLE_ADMIN'))
|
||||||
const me = auth.user as any
|
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
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 { typeBadgeClass, formatDate } = useClientTicketHelpers()
|
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 {
|
function statusDotClass(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -128,28 +167,118 @@ function statusDotClass(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = computed(() => statuses.map(status => ({
|
const columns = computed(() => allStatuses.map(status => ({
|
||||||
status,
|
status,
|
||||||
label: t(`clientTicket.status.${status}`),
|
label: t(`clientTicket.status.${status}`),
|
||||||
dotClass: statusDotClass(status),
|
dotClass: statusDotClass(status),
|
||||||
tickets: tickets.value.filter(tk => tk.status === 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) {
|
function openDetail(ticket: ClientTicket) {
|
||||||
selectedTicket.value = ticket
|
selectedTicket.value = ticket
|
||||||
detailOpen.value = true
|
detailOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTickets() {
|
async function loadData() {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
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 {
|
} finally {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTickets() {
|
||||||
|
tickets.value = await clientTicketService.getAll({ project: projectId.value })
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadTickets()
|
loadData()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ project?.name ?? '' }}</h1>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ProjectClientTickets
|
|
||||||
v-if="project"
|
|
||||||
:project-id="projectId"
|
|
||||||
:project-name="project.name"
|
|
||||||
/>
|
|
||||||
<button
|
<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"
|
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"
|
@click="openTaskCreate"
|
||||||
|
|||||||
Reference in New Issue
Block a user