big refactor
This commit is contained in:
346
app/pages/back-office/workload/kanban.vue
Normal file
346
app/pages/back-office/workload/kanban.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user