Files
policy-ui/app/pages/back-office/workload/kanban.vue
2026-04-29 16:25:11 -05:00

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>