347 lines
9.5 KiB
Vue
347 lines
9.5 KiB
Vue
<script setup lang="ts">
|
|
import type { SelectItem } from '@nuxt/ui'
|
|
import KanbanColumn from '~/components/back-office/KanbanColumn.vue'
|
|
import KanbanTaskCard from '~/components/back-office/KanbanTaskCard.vue'
|
|
|
|
usePageTitle('Workload Kanban')
|
|
|
|
// Stage configuration for colors
|
|
const stageConfig: Record<string, {
|
|
color: string;
|
|
dot: string;
|
|
headerBg: string;
|
|
}> = {
|
|
created: {
|
|
color: 'text-[var(--text-muted)]',
|
|
dot: 'bg-[var(--text-muted)]',
|
|
headerBg: 'bg-[var(--surface)] border-[var(--card-border)]'
|
|
},
|
|
draft: {
|
|
color: 'text-[var(--brand)]',
|
|
dot: 'bg-[var(--brand)]',
|
|
headerBg: 'bg-[var(--brand-faint)] border-[var(--brand-soft)]'
|
|
},
|
|
approved: {
|
|
color: 'text-emerald-700',
|
|
dot: 'bg-emerald-500',
|
|
headerBg: 'bg-emerald-50 border-emerald-200'
|
|
},
|
|
completed: {
|
|
color: 'text-gray-500',
|
|
dot: 'bg-gray-400',
|
|
headerBg: 'bg-gray-50 border-gray-200'
|
|
}
|
|
}
|
|
|
|
const policyTypeColors: Record<string, string> = {
|
|
car: 'bg-blue-100 text-blue-700',
|
|
life: 'bg-violet-100 text-violet-700',
|
|
fire: 'bg-amber-100 text-amber-700'
|
|
}
|
|
|
|
function daysInStage(createdAt: string): string {
|
|
const days = Math.floor((Date.now() - new Date(createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
|
return days === 0 ? 'Today' : `${days}d`
|
|
}
|
|
|
|
function taskUrgent(task: any): boolean {
|
|
// Placeholder - will be based on priority/due_date from backend
|
|
return false
|
|
}
|
|
|
|
const page = ref(1)
|
|
const filters = ref({
|
|
status: null as string | null,
|
|
policyType: null as string | null,
|
|
search: ''
|
|
})
|
|
|
|
// Mock user context for now
|
|
const currentUser = ref({
|
|
id: 'user-123',
|
|
name: 'Current User'
|
|
})
|
|
|
|
const statusItems = ref<SelectItem[]>([
|
|
{ label: 'All Statuses', value: null },
|
|
{ label: 'Created', value: 'created' },
|
|
{ label: 'Draft', value: 'draft' },
|
|
{ label: 'Approved', value: 'approved' },
|
|
{ label: 'Completed', value: 'completed' }
|
|
])
|
|
|
|
const policyTypeItems = ref<SelectItem[]>([
|
|
{ label: 'All Types', value: null },
|
|
{ label: 'Car', value: 'car' },
|
|
{ label: 'Life', value: 'life' },
|
|
{ label: 'Fire', value: 'fire' }
|
|
])
|
|
|
|
watch([() => filters.value.status, () => filters.value.policyType, () => filters.value.search], () => {
|
|
page.value = 1
|
|
})
|
|
|
|
const { data, pending, error, refresh } = useWorkload('/tasks', {
|
|
query: computed(() => ({
|
|
page: page.value,
|
|
page_size: 100,
|
|
...(filters.value.status && {
|
|
'filters[0][field]': 'status',
|
|
'filters[0][op]': '==',
|
|
'filters[0][value]': filters.value.status
|
|
}),
|
|
...(filters.value.policyType && {
|
|
'filters[1][field]': 'policy_type',
|
|
'filters[1][op]': '==',
|
|
'filters[1][value]': filters.value.policyType
|
|
}),
|
|
...(filters.value.search && {
|
|
'filters[2][field]': 'application_id',
|
|
'filters[2][op]': 'contains',
|
|
'filters[2][value]': filters.value.search
|
|
})
|
|
}))
|
|
})
|
|
|
|
const allTasks = computed(() => data.value?.data ?? [])
|
|
|
|
const tasksByStatus = computed(() => ({
|
|
created: allTasks.value.filter(t => t.status === 'created'),
|
|
draft: allTasks.value.filter(t => t.status === 'draft'),
|
|
approved: allTasks.value.filter(t => t.status === 'approved'),
|
|
completed: allTasks.value.filter(t => t.status === 'completed')
|
|
}))
|
|
|
|
const columns = computed(() => [
|
|
{ title: 'Created', status: 'created' },
|
|
{ title: 'Draft', status: 'draft' },
|
|
{ title: 'Approved', status: 'approved' },
|
|
{ title: 'Completed', status: 'completed' }
|
|
])
|
|
|
|
// Mobile view state
|
|
const activeMobileColumn = ref('created')
|
|
const mobileColumns = ['created', 'draft', 'approved', 'completed']
|
|
|
|
// Auto-refresh
|
|
const autoRefreshInterval = ref(30)
|
|
const lastRefreshTime = ref<Date | null>(null)
|
|
const isAutoRefreshing = ref(true)
|
|
let refreshTimer: NodeJS.Timeout | null = null
|
|
|
|
function startAutoRefresh() {
|
|
if (refreshTimer) clearInterval(refreshTimer)
|
|
|
|
refreshTimer = setInterval(() => {
|
|
if (isAutoRefreshing.value) {
|
|
refresh()
|
|
lastRefreshTime.value = new Date()
|
|
}
|
|
}, autoRefreshInterval.value * 1000)
|
|
}
|
|
|
|
function stopAutoRefresh() {
|
|
if (refreshTimer) {
|
|
clearInterval(refreshTimer)
|
|
refreshTimer = null
|
|
}
|
|
}
|
|
|
|
function toggleAutoRefresh() {
|
|
isAutoRefreshing.value = !isAutoRefreshing.value
|
|
}
|
|
|
|
onMounted(() => {
|
|
startAutoRefresh()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopAutoRefresh()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="kanban-board flex-1 flex flex-col min-h-0">
|
|
<!-- Header -->
|
|
<div class="flex items-start justify-between mb-4">
|
|
<div>
|
|
<h1 class="text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Workload Kanban</h1>
|
|
<p class="text-sm text-[var(--text-muted)]">{{ allTasks.length }} tasks</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-2 text-[12px] text-[var(--text-muted)]">
|
|
<span class="flex items-center gap-1">
|
|
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[0].status].dot" />
|
|
<span class="text-[11px]">{{ columns[0].title }}</span>
|
|
<span class="font-semibold">{{ tasksByStatus[columns[0].status].length }}</span>
|
|
</span>
|
|
<span class="text-[var(--text-muted)] opacity-50">·</span>
|
|
<span class="flex items-center gap-1">
|
|
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[1].status].dot" />
|
|
<span class="text-[11px]">{{ columns[1].title }}</span>
|
|
<span class="font-semibold">{{ tasksByStatus[columns[1].status].length }}</span>
|
|
</span>
|
|
<span class="text-[var(--text-muted)] opacity-50">·</span>
|
|
<span class="flex items-center gap-1">
|
|
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[2].status].dot" />
|
|
<span class="text-[11px]">{{ columns[2].title }}</span>
|
|
<span class="font-semibold">{{ tasksByStatus[columns[2].status].length }}</span>
|
|
</span>
|
|
<span class="text-[var(--text-muted)] opacity-50">·</span>
|
|
<span class="flex items-center gap-1">
|
|
<span class="h-2 w-2 rounded-full" :class="stageConfig[columns[3].status].dot" />
|
|
<span class="text-[11px]">{{ columns[3].title }}</span>
|
|
<span class="font-semibold">{{ tasksByStatus[columns[3].status].length }}</span>
|
|
</span>
|
|
</div>
|
|
<UButton
|
|
icon="i-heroicons-arrow-path"
|
|
color="gray"
|
|
variant="soft"
|
|
:loading="pending"
|
|
@click="refresh"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex flex-wrap items-center gap-3 rounded-xl border border-[var(--card-border)] bg-[var(--surface)] px-4 py-3 shadow-sm ring-1 ring-[var(--surface)] mb-4">
|
|
<UInput
|
|
v-model="filters.search"
|
|
placeholder="Search by application ID..."
|
|
icon="i-heroicons-magnifying-glass"
|
|
size="sm"
|
|
class="w-64"
|
|
/>
|
|
|
|
<USelect
|
|
v-model="filters.status"
|
|
:items="statusItems"
|
|
size="sm"
|
|
class="w-36"
|
|
/>
|
|
|
|
<USelect
|
|
v-model="filters.policyType"
|
|
:items="policyTypeItems"
|
|
size="sm"
|
|
class="w-36"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<UAlert
|
|
v-if="error"
|
|
color="red"
|
|
variant="soft"
|
|
title="Failed to load tasks"
|
|
:description="error.message"
|
|
class="mb-4"
|
|
/>
|
|
|
|
<!-- Desktop: 4-column layout -->
|
|
<div class="kanban-columns desktop flex-1 min-h-0 overflow-x-auto pb-2 flex flex-col">
|
|
<div class="flex gap-3 flex-1 min-h-0">
|
|
<div
|
|
v-for="column in columns"
|
|
:key="column.status"
|
|
class="flex w-[240px] shrink-0 flex-col"
|
|
>
|
|
<KanbanColumn
|
|
:title="column.title"
|
|
:status="column.status"
|
|
:tasks="tasksByStatus[column.status]"
|
|
:loading="pending"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile: Single column with tabs -->
|
|
<div class="kanban-columns mobile flex-1 min-h-0 flex flex-col">
|
|
<div class="mobile-tabs flex-shrink-0">
|
|
<button
|
|
v-for="status in mobileColumns"
|
|
:key="status"
|
|
:class="['tab-btn', { active: activeMobileColumn === status }]"
|
|
@click="activeMobileColumn = status"
|
|
>
|
|
{{ status.charAt(0).toUpperCase() + status.slice(1) }}
|
|
({{ tasksByStatus[status].length }})
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex-1 min-h-0 overflow-y-auto">
|
|
<KanbanColumn
|
|
:title="activeMobileColumn.charAt(0).toUpperCase() + activeMobileColumn.slice(1)"
|
|
:status="activeMobileColumn"
|
|
:tasks="tasksByStatus[activeMobileColumn]"
|
|
:loading="pending"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.kanban-board {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.25rem;
|
|
}
|
|
|
|
.filter-bar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.kanban-columns.mobile {
|
|
display: none;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.mobile-tabs {
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
overflow-x: auto;
|
|
padding-bottom: 0.5rem;
|
|
}
|
|
|
|
.tab-btn {
|
|
flex-shrink: 0;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 0.375rem;
|
|
border: 1px solid var(--card-border);
|
|
background: var(--surface);
|
|
color: var(--text-muted);
|
|
font-size: 0.8125rem;
|
|
font-weight: 500;
|
|
white-space: nowrap;
|
|
transition: all 0.15s ease;
|
|
}
|
|
|
|
.tab-btn.active {
|
|
background: var(--brand);
|
|
color: white;
|
|
border-color: var(--brand);
|
|
}
|
|
|
|
.tab-btn:hover:not(.active) {
|
|
background: var(--card-border);
|
|
}
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.kanban-columns.desktop {
|
|
display: none;
|
|
}
|
|
|
|
.kanban-columns.mobile {
|
|
display: flex;
|
|
}
|
|
}
|
|
</style>
|