big refactor
This commit is contained in:
153
app/components/back-office/KanbanColumn.vue
Normal file
153
app/components/back-office/KanbanColumn.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import KanbanTaskCard from './KanbanTaskCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
status: string
|
||||
tasks: any[]
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function navigateToTask(task: any) {
|
||||
router.push(`/back-office/workload/${encodeURIComponent(task.id)}`)
|
||||
}
|
||||
|
||||
// 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'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kanban-column">
|
||||
<div class="column-header" :class="stageConfig[status].headerBg">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full" :class="stageConfig[status].dot" />
|
||||
<span class="text-[13px] font-semibold" :class="stageConfig[status].color">
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-[var(--surface)]/70 px-1.5 text-[11px] font-semibold text-[var(--text-muted)] ring-1 ring-[var(--card-border)]/60">
|
||||
{{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-list">
|
||||
<KanbanTaskCard
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
:task="task"
|
||||
:urgent="taskUrgent(task)"
|
||||
@click="navigateToTask(task)"
|
||||
/>
|
||||
|
||||
<div v-if="tasks.length === 0 && !loading" class="empty-state">
|
||||
<UIcon name="i-heroicons-inbox" class="w-8 h-8" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div v-for="n in 3" :key="n" class="skeleton-card" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-column {
|
||||
flex: 0 0 240px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--card-border);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
background: var(--surface);
|
||||
border-radius: 8px 8px 0 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 16px;
|
||||
color: var(--text-muted);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 100px;
|
||||
background: var(--card-border);
|
||||
border-radius: 6px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
</style>
|
||||
110
app/components/back-office/KanbanTaskCard.vue
Normal file
110
app/components/back-office/KanbanTaskCard.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
task: any
|
||||
urgent?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [task: any]
|
||||
}>()
|
||||
|
||||
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 onClick() {
|
||||
emit('click', props.task)
|
||||
}
|
||||
|
||||
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 formatDate(date: string, format: 'short' = 'short'): string {
|
||||
if (!date) return '—'
|
||||
const d = new Date(date)
|
||||
if (format === 'short') {
|
||||
return d.toLocaleDateString('es-PA', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
return d.toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="kanban-task-card rounded-xl border px-3.5 py-3 shadow-sm ring-1 transition hover:shadow-md"
|
||||
:class="urgent
|
||||
? 'border-rose-300 bg-rose-50/50 ring-rose-100 hover:border-rose-400'
|
||||
: 'border-[var(--card-border)] bg-[var(--surface)] ring-[var(--surface)] hover:border-[var(--brand)]/30'"
|
||||
@click="onClick"
|
||||
>
|
||||
<!-- Header row: Application ID + Policy type badge -->
|
||||
<div class="flex items-start justify-between gap-1 mb-1">
|
||||
<p class="text-[13px] font-mono font-semibold leading-tight text-[var(--text-primary)] truncate">
|
||||
{{ task.application_id }}
|
||||
</p>
|
||||
<span
|
||||
class="ml-1 shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
|
||||
:class="policyTypeColors[task.policy_type] || 'bg-gray-100 text-gray-700'"
|
||||
>
|
||||
{{ task.policy_type?.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Provider name -->
|
||||
<p class="text-[12px] text-[var(--text-muted)] truncate">
|
||||
{{ task.provider_name || 'Unknown Provider' }}
|
||||
</p>
|
||||
|
||||
<!-- Urgent flag -->
|
||||
<span v-if="urgent" class="mt-1.5 inline-flex items-center gap-1 rounded-full bg-rose-100 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide text-rose-600">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-rose-500" />
|
||||
Urgent
|
||||
</span>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="mt-2.5 flex items-center justify-between border-t border-[var(--divider)] pt-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Assignee -->
|
||||
<div v-if="task.assignee" class="flex items-center gap-1 text-[11px] text-[var(--text-muted)]">
|
||||
<UIcon name="i-heroicons-user" class="w-3 h-3" />
|
||||
<span class="truncate max-w-[60px]">{{ task.assignee.name }}</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 text-[11px] text-[var(--text-muted)] opacity-50">
|
||||
<UIcon name="i-heroicons-user-minus" class="w-3 h-3" />
|
||||
<span>Unassigned</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-[11px] text-[var(--text-muted)]">
|
||||
<!-- Due date if present -->
|
||||
<span v-if="task.due_date" class="truncate">
|
||||
Due: {{ formatDate(task.due_date, 'short') }}
|
||||
</span>
|
||||
<!-- Days in stage -->
|
||||
<span class="flex items-center gap-0.5">
|
||||
<UIcon name="i-heroicons-clock" class="w-3 h-3" />
|
||||
{{ daysInStage(task.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kanban-task-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.kanban-task-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
|
||||
118
app/components/back-office/NestedJsonViewer.vue
Normal file
118
app/components/back-office/NestedJsonViewer.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'NestedJsonViewer' })
|
||||
|
||||
interface Props {
|
||||
data: any
|
||||
depth?: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const depth = computed(() => props.depth || 0)
|
||||
|
||||
// Font size decreases with depth: 12px base, -1px per level
|
||||
const fontSize = computed(() => Math.max(10, 12 - depth.value * 1))
|
||||
|
||||
// Padding decreases with depth
|
||||
const padding = computed(() => Math.max(1, 4 - depth.value))
|
||||
|
||||
function isObject(value: any): boolean {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isArray(value: any): boolean {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
function isDateString(value: string): boolean {
|
||||
if (typeof value !== 'string') return false
|
||||
return /^\d{4}-\d{2}-\d{2}/.test(value) && !isNaN(Date.parse(value))
|
||||
}
|
||||
|
||||
function formatValue(value: any): string {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No'
|
||||
if (typeof value === 'number') return value.toLocaleString()
|
||||
if (isDateString(value)) {
|
||||
try {
|
||||
return new Date(value).toLocaleDateString('es-PA', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatKey(key: string): string {
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="nested-json-viewer" :style="{ fontSize: fontSize + 'px' }">
|
||||
<!-- Object: render as table -->
|
||||
<table v-if="isObject(data) && Object.keys(data).length > 0" class="json-table border rounded overflow-hidden mb-2">
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in data" :key="key" class="border-t border-gray-200 hover:bg-gray-50">
|
||||
<td class="field-name px-3 py-2 font-medium text-gray-600 align-top whitespace-nowrap" :style="{ padding: padding + 'px', fontSize: (fontSize - 1) + 'px' }">
|
||||
{{ formatKey(key) }}
|
||||
</td>
|
||||
<td class="field-value px-3 py-2 text-gray-900 align-top" :style="{ padding: padding + 'px' }">
|
||||
<NestedJsonViewer :data="value" :depth="depth + 1" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else-if="isObject(data) && Object.keys(data).length === 0" class="text-gray-400 italic" :style="{ fontSize: fontSize + 'px' }">
|
||||
Empty object
|
||||
</div>
|
||||
|
||||
<!-- Array -->
|
||||
<div v-else-if="isArray(data)" class="array-container">
|
||||
<div v-for="(item, index) in data" :key="index" class="array-item mb-2 border rounded p-2">
|
||||
<div class="text-xs font-semibold text-gray-500 mb-1">[{{ index }}]</div>
|
||||
<NestedJsonViewer :data="item" :depth="depth + 1" />
|
||||
</div>
|
||||
<div v-if="data.length === 0" class="text-gray-400 italic" :style="{ fontSize: fontSize + 'px' }">
|
||||
Empty array
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Primitive -->
|
||||
<span v-else class="primitive-value" :class="{ 'text-gray-400': data === '—' }">
|
||||
{{ formatValue(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nested-json-viewer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.json-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.field-name {
|
||||
min-width: 120px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.array-item {
|
||||
background: white;
|
||||
border-color: var(--card-border, #e5e7eb);
|
||||
}
|
||||
</style>
|
||||
42
app/components/back-office/QuoteTaskInfoDisplay.vue
Normal file
42
app/components/back-office/QuoteTaskInfoDisplay.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
task: any // TODO: Type properly
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const formatDate = (d: string) => d
|
||||
? new Date(d).toLocaleDateString('es-PA', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '—'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 text-sm">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div class="text-gray-500">Task ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.id }}</div>
|
||||
|
||||
<div class="text-gray-500">Application ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.application_id }}</div>
|
||||
|
||||
<div class="text-gray-500">Provider ID</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.task_info?.provider_id }}</div>
|
||||
|
||||
<div v-if="task.task_info?.provider_name" class="text-gray-500">Provider</div>
|
||||
<div v-if="task.task_info?.provider_name" class="text-right">{{ task.task_info.provider_name }}</div>
|
||||
|
||||
<div class="text-gray-500">Org</div>
|
||||
<div class="font-mono text-xs text-right">{{ task.org_id }}</div>
|
||||
|
||||
<div class="text-gray-500">Created</div>
|
||||
<div class="text-right">{{ formatDate(task.created_at) }}</div>
|
||||
|
||||
<div class="text-gray-500">Updated</div>
|
||||
<div class="text-right">{{ formatDate(task.updated_at) }}</div>
|
||||
|
||||
<div class="text-gray-500">Policy Type</div>
|
||||
<div class="text-right uppercase text-xs">{{ task.task_info?.policy_type || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
74
app/components/back-office/SolicitationTaskInfoDisplay.vue
Normal file
74
app/components/back-office/SolicitationTaskInfoDisplay.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
task: any // TODO: Type properly
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Applicant Info -->
|
||||
<div v-if="task.task_info?.applicant_info">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Applicant Information</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.applicant_info" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vehicle Info -->
|
||||
<div v-if="task.task_info?.vehicle_info">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Vehicle Information</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.vehicle_info" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Policy Details -->
|
||||
<div v-if="task.task_info?.policy_details">
|
||||
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Policy Details</h3>
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Field</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="(value, key) in task.task_info.policy_details" :key="key">
|
||||
<td class="px-3 py-2 text-gray-600 font-medium capitalize">{{ key.replace(/_/g, ' ') }}</td>
|
||||
<td class="px-3 py-2 text-gray-900">{{ value || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user