Give kanban containers a fixed viewport height. Column headers stay fixed while task cards scroll independently within each column. Ticket: LST-28 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
10 KiB
Vue
285 lines
10 KiB
Vue
<template>
|
|
<div>
|
|
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<NuxtLink
|
|
to="/portal"
|
|
class="text-sm text-neutral-400 hover:text-primary-500"
|
|
>
|
|
{{ $t('portal.backToProject') }}
|
|
</NuxtLink>
|
|
<h1 class="mt-1 text-xl font-bold text-primary-500 sm:text-2xl">{{ projectName }}</h1>
|
|
</div>
|
|
<NuxtLink
|
|
v-if="isClient"
|
|
:to="`/portal/projects/${projectId}/new-ticket`"
|
|
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"
|
|
>
|
|
<span class="hidden sm:inline">+ {{ $t('portal.newTicket') }}</span>
|
|
<span class="sm:hidden">+ Ticket</span>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('common.loading') }}
|
|
</div>
|
|
|
|
<div v-else-if="tickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('clientTicket.noTickets') }}
|
|
</div>
|
|
|
|
<!-- Kanban board -->
|
|
<div v-else class="mt-4 flex h-[calc(100vh-200px)] flex-col gap-4 sm:flex-row sm:overflow-x-auto sm:pb-4">
|
|
<div
|
|
v-for="col in columns"
|
|
:key="col.status"
|
|
class="flex min-w-0 flex-1 flex-col sm:min-w-[280px]"
|
|
>
|
|
<div class="mb-3 flex shrink-0 items-center gap-2">
|
|
<div class="h-2 w-2 rounded-full" :class="col.dotClass" />
|
|
<h3 class="text-sm font-bold text-neutral-700">{{ col.label }}</h3>
|
|
<span class="ml-auto rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-semibold text-neutral-500">
|
|
{{ col.tickets.length }}
|
|
</span>
|
|
</div>
|
|
<div
|
|
class="min-h-0 flex-1 space-y-2 overflow-y-auto 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">
|
|
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:class="typeBadgeClass(ticket.type)"
|
|
>
|
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
|
</span>
|
|
</div>
|
|
<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
|
|
v-if="col.tickets.length === 0"
|
|
class="py-4 text-center text-xs text-neutral-400"
|
|
>
|
|
{{ $t('clientTicket.noTickets') }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ticket detail modal -->
|
|
<ClientTicketDetailModal
|
|
v-model="detailOpen"
|
|
: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, ClientTicketStatus } from '~/services/dto/client-ticket'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
import { useProjectService } from '~/services/projects'
|
|
|
|
definePageMeta({
|
|
layout: 'portal',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const { t } = useI18n()
|
|
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 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 allStatuses: ClientTicketStatus[] = ['new', 'in_progress', 'done', 'rejected']
|
|
|
|
function statusDotClass(status: string): string {
|
|
switch (status) {
|
|
case 'new': return 'bg-blue-500'
|
|
case 'in_progress': return 'bg-yellow-500'
|
|
case 'done': return 'bg-green-500'
|
|
case 'rejected': return 'bg-red-500'
|
|
default: return 'bg-neutral-400'
|
|
}
|
|
}
|
|
|
|
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 loadData() {
|
|
isLoading.value = true
|
|
try {
|
|
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(() => {
|
|
loadData()
|
|
})
|
|
</script>
|