3362 lines
134 KiB
Vue
3362 lines
134 KiB
Vue
<script setup lang="ts">
|
||
import type { WelcomeDashboardKpi } from '~/types/welcome-dashboard'
|
||
import {
|
||
DASHBOARD_PRESET_ORDER,
|
||
DASHBOARD_ROLE_PRESETS,
|
||
DASHBOARD_WIDGETS,
|
||
type DashboardRolePresetId,
|
||
type DashboardWidgetId
|
||
} from '~/composables/useDashboardHomeWidgets'
|
||
|
||
const { saved: homeBranding } = useBrokerageBranding()
|
||
const welcome = useWelcomeDashboard()
|
||
|
||
const {
|
||
widgets,
|
||
widgetOrder,
|
||
layoutUnlocked,
|
||
activePreset,
|
||
isPresetDirty,
|
||
applyPreset,
|
||
setWidget,
|
||
reapplySelectedPreset,
|
||
reorderWidgets
|
||
} = useDashboardHomeWidgets()
|
||
|
||
const dashConfigOpen = ref(false)
|
||
const draggingWidget = ref<DashboardWidgetId | null>(null)
|
||
|
||
function onDragStart(wid: DashboardWidgetId, e: DragEvent) {
|
||
if (!layoutUnlocked.value) { e.preventDefault(); return }
|
||
draggingWidget.value = wid
|
||
try { e.dataTransfer?.setData('text/plain', wid); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move' } catch { /* */ }
|
||
}
|
||
function onDragEnd() { draggingWidget.value = null }
|
||
function onDropSection(target: DashboardWidgetId, e: DragEvent) {
|
||
e.preventDefault()
|
||
if (!layoutUnlocked.value) return
|
||
const raw = draggingWidget.value ?? e.dataTransfer?.getData('text/plain')
|
||
const from = raw as DashboardWidgetId | undefined
|
||
if (!from || from === target) return
|
||
reorderWidgets(from, target)
|
||
draggingWidget.value = null
|
||
}
|
||
function toggleLayoutUnlock() { layoutUnlocked.value = !layoutUnlocked.value }
|
||
|
||
/* ---- Notes scratchpad (sticky notes, persisted locally) ---- */
|
||
const NOTES_KEY = 'policy-ui.dashboard.notes.v2'
|
||
|
||
interface StickyNote {
|
||
id: string
|
||
content: string
|
||
color: string
|
||
expanded: boolean
|
||
pinned: boolean
|
||
createdAt: number
|
||
}
|
||
|
||
const STICKY_COLORS = [
|
||
{ id: 'yellow', bg: '#fef9c3', border: '#fde047', label: 'Yellow' },
|
||
{ id: 'green', bg: '#dcfce7', border: '#86efac', label: 'Green' },
|
||
{ id: 'blue', bg: '#dbeafe', border: '#93c5fd', label: 'Blue' },
|
||
{ id: 'pink', bg: '#fce7f3', border: '#f9a8d4', label: 'Pink' },
|
||
{ id: 'orange', bg: '#ffedd5', border: '#fdba74', label: 'Orange' },
|
||
]
|
||
|
||
const stickyNotes = ref<StickyNote[]>([
|
||
{ id: 'n1', content: 'Follow up with Constructora Delta on fleet renewal — need 3 comparative quotes by Friday.', color: 'yellow', expanded: false, pinned: true, createdAt: Date.now() - 86400000 },
|
||
{ id: 'n2', content: 'Ask Marco about the Clínica Norte health plan upsell.', color: 'green', expanded: false, pinned: false, createdAt: Date.now() - 3600000 },
|
||
{ id: 'n3', content: 'Board deck due end of month — pull YTD numbers from the pipeline report and retention dashboard.', color: 'blue', expanded: false, pinned: false, createdAt: Date.now() - 7200000 },
|
||
{ id: 'n4', content: 'Call Sofía re: grace period.', color: 'pink', expanded: false, pinned: false, createdAt: Date.now() - 1800000 },
|
||
])
|
||
let nextNoteId = 5
|
||
|
||
function addStickyNote() {
|
||
const colors = STICKY_COLORS.map(c => c.id)
|
||
const color = colors[stickyNotes.value.length % colors.length] ?? 'yellow'
|
||
const note: StickyNote = { id: `n${nextNoteId++}`, content: '', color, expanded: true, pinned: false, createdAt: Date.now() }
|
||
stickyNotes.value = [note, ...stickyNotes.value]
|
||
}
|
||
|
||
function removeStickyNote(id: string) {
|
||
stickyNotes.value = stickyNotes.value.filter(n => n.id !== id)
|
||
}
|
||
|
||
function toggleStickyExpand(id: string) {
|
||
const note = stickyNotes.value.find(n => n.id === id)
|
||
if (note) note.expanded = !note.expanded
|
||
}
|
||
|
||
function toggleStickyPin(id: string) {
|
||
const note = stickyNotes.value.find(n => n.id === id)
|
||
if (note) note.pinned = !note.pinned
|
||
}
|
||
|
||
function stickyColor(colorId: string) {
|
||
return STICKY_COLORS.find(c => c.id === colorId) ?? STICKY_COLORS[0]!
|
||
}
|
||
|
||
function cycleStickyColor(id: string) {
|
||
const note = stickyNotes.value.find(n => n.id === id)
|
||
if (!note) return
|
||
const idx = STICKY_COLORS.findIndex(c => c.id === note.color)
|
||
note.color = STICKY_COLORS[(idx + 1) % STICKY_COLORS.length]!.id
|
||
}
|
||
|
||
function stickyPreview(content: string) {
|
||
if (!content) return 'Empty note'
|
||
return content.length > 60 ? content.slice(0, 60) + '...' : content
|
||
}
|
||
|
||
const sortedStickyNotes = computed(() => {
|
||
const pinned = stickyNotes.value.filter(n => n.pinned)
|
||
const unpinned = stickyNotes.value.filter(n => !n.pinned)
|
||
return [...pinned, ...unpinned]
|
||
})
|
||
|
||
onMounted(() => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
const raw = localStorage.getItem(NOTES_KEY)
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed)) stickyNotes.value = parsed
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
})
|
||
watch(stickyNotes, (v) => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try { localStorage.setItem(NOTES_KEY, JSON.stringify(v)) } catch { /* quota */ }
|
||
}
|
||
}, { deep: true })
|
||
|
||
/* ---- Tasks & alerts interactivity ---- */
|
||
interface DashTask {
|
||
id: string
|
||
title: string
|
||
emphasis: boolean
|
||
done: boolean
|
||
}
|
||
|
||
const dashTasks = ref<DashTask[]>([])
|
||
const dashAlertsDismissed = ref<Set<string>>(new Set())
|
||
const newTaskText = ref('')
|
||
const addingTask = ref(false)
|
||
|
||
onMounted(() => {
|
||
// Hydrate tasks from composable data + localStorage state
|
||
const savedDone = new Set<string>()
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
const raw = localStorage.getItem('policy-ui.dashboard.tasks-done')
|
||
if (raw) JSON.parse(raw).forEach((id: string) => savedDone.add(id))
|
||
} catch { /* */ }
|
||
try {
|
||
const raw = localStorage.getItem('policy-ui.dashboard.alerts-dismissed')
|
||
if (raw) JSON.parse(raw).forEach((id: string) => dashAlertsDismissed.value.add(id))
|
||
} catch { /* */ }
|
||
try {
|
||
const raw = localStorage.getItem('policy-ui.dashboard.user-tasks')
|
||
if (raw) {
|
||
const userTasks = JSON.parse(raw) as DashTask[]
|
||
// Will merge below
|
||
dashTasks.value = [
|
||
...welcome.value.dailyTasks.map(t => ({
|
||
id: t.id,
|
||
title: t.title,
|
||
emphasis: t.emphasis,
|
||
done: savedDone.has(t.id),
|
||
})),
|
||
...userTasks,
|
||
]
|
||
return
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
dashTasks.value = welcome.value.dailyTasks.map(t => ({
|
||
id: t.id,
|
||
title: t.title,
|
||
emphasis: t.emphasis,
|
||
done: savedDone.has(t.id),
|
||
}))
|
||
})
|
||
|
||
function toggleTask(id: string) {
|
||
const task = dashTasks.value.find(t => t.id === id)
|
||
if (task) {
|
||
task.done = !task.done
|
||
persistTaskState()
|
||
}
|
||
}
|
||
|
||
function addQuickTask() {
|
||
if (!newTaskText.value.trim()) return
|
||
const task: DashTask = {
|
||
id: `user-task-${Date.now()}`,
|
||
title: newTaskText.value.trim(),
|
||
emphasis: false,
|
||
done: false,
|
||
}
|
||
dashTasks.value = [task, ...dashTasks.value]
|
||
newTaskText.value = ''
|
||
addingTask.value = false
|
||
persistTaskState()
|
||
}
|
||
|
||
function removeTask(id: string) {
|
||
dashTasks.value = dashTasks.value.filter(t => t.id !== id)
|
||
persistTaskState()
|
||
}
|
||
|
||
function dismissAlert(id: string) {
|
||
dashAlertsDismissed.value.add(id)
|
||
if (typeof localStorage !== 'undefined') {
|
||
try { localStorage.setItem('policy-ui.dashboard.alerts-dismissed', JSON.stringify([...dashAlertsDismissed.value])) } catch { /* */ }
|
||
}
|
||
}
|
||
|
||
function persistTaskState() {
|
||
if (typeof localStorage === 'undefined') return
|
||
try {
|
||
const doneIds = dashTasks.value.filter(t => t.done).map(t => t.id)
|
||
localStorage.setItem('policy-ui.dashboard.tasks-done', JSON.stringify(doneIds))
|
||
const userTasks = dashTasks.value.filter(t => t.id.startsWith('user-task-'))
|
||
localStorage.setItem('policy-ui.dashboard.user-tasks', JSON.stringify(userTasks))
|
||
} catch { /* */ }
|
||
}
|
||
|
||
const visibleAlerts = computed(() =>
|
||
alertsWithMeta.value.filter(a => !dashAlertsDismissed.value.has(a.id))
|
||
)
|
||
|
||
const completedTaskCount = computed(() => dashTasks.value.filter(t => t.done).length)
|
||
const totalTaskCount = computed(() => dashTasks.value.length)
|
||
|
||
/* ---- Milestone tracker configurability ---- */
|
||
type MilestoneMetric = { id: string; label: string; actual: string; target: string; pct: number; unit: string }
|
||
|
||
const MILESTONE_METRICS: MilestoneMetric[] = [
|
||
{ id: 'premium', label: 'Premium', actual: '$1.24M', target: '$1.18M', pct: 105, unit: '$' },
|
||
{ id: 'policies', label: 'Policies', actual: '42', target: '40', pct: 105, unit: '' },
|
||
{ id: 'quotes', label: 'Quotes sent', actual: '68', target: '55', pct: 124, unit: '' },
|
||
{ id: 'close-rate', label: 'Close rate', actual: '34%', target: '30%', pct: 113, unit: '%' },
|
||
{ id: 'new-clients', label: 'New clients', actual: '8', target: '6', pct: 133, unit: '' },
|
||
{ id: 'retention', label: 'Retention', actual: '91%', target: '88%', pct: 103, unit: '%' },
|
||
]
|
||
|
||
const MILESTONE_STORAGE_KEY = 'policy-ui.dashboard.milestone-metrics'
|
||
const activeMilestoneIds = ref<string[]>(['premium', 'policies'])
|
||
|
||
onMounted(() => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
const raw = localStorage.getItem(MILESTONE_STORAGE_KEY)
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed) && parsed.length > 0) activeMilestoneIds.value = parsed
|
||
}
|
||
} catch { /* */ }
|
||
}
|
||
})
|
||
|
||
function toggleMilestoneMetric(id: string) {
|
||
const idx = activeMilestoneIds.value.indexOf(id)
|
||
if (idx >= 0) {
|
||
if (activeMilestoneIds.value.length <= 1) return
|
||
activeMilestoneIds.value = activeMilestoneIds.value.filter(x => x !== id)
|
||
} else {
|
||
if (activeMilestoneIds.value.length >= 4) return
|
||
activeMilestoneIds.value = [...activeMilestoneIds.value, id]
|
||
}
|
||
if (typeof localStorage !== 'undefined') {
|
||
try { localStorage.setItem(MILESTONE_STORAGE_KEY, JSON.stringify(activeMilestoneIds.value)) } catch { /* */ }
|
||
}
|
||
}
|
||
|
||
const activeMilestones = computed(() =>
|
||
activeMilestoneIds.value
|
||
.map(id => MILESTONE_METRICS.find(m => m.id === id))
|
||
.filter((m): m is MilestoneMetric => !!m)
|
||
)
|
||
|
||
const milestoneAvgPct = computed(() => {
|
||
if (!activeMilestones.value.length) return 0
|
||
return Math.round(activeMilestones.value.reduce((s, m) => s + m.pct, 0) / activeMilestones.value.length)
|
||
})
|
||
|
||
const milestoneStatus = computed(() => {
|
||
const avg = milestoneAvgPct.value
|
||
if (avg >= 100) return { label: 'On track', color: 'var(--h2-success)', icon: 'i-heroicons-check-badge' }
|
||
if (avg >= 85) return { label: 'Close', color: 'var(--h2-warning)', icon: 'i-heroicons-exclamation-circle' }
|
||
return { label: 'Behind', color: 'var(--h2-error)', icon: 'i-heroicons-x-circle' }
|
||
})
|
||
|
||
const milestoneConfigOpen = ref(false)
|
||
let milestoneConfigSkipClose = false
|
||
|
||
function onMilestoneConfigClick(e: MouseEvent) {
|
||
e.stopPropagation()
|
||
e.preventDefault()
|
||
milestoneConfigSkipClose = true
|
||
milestoneConfigOpen.value = !milestoneConfigOpen.value
|
||
setTimeout(() => { milestoneConfigSkipClose = false }, 200)
|
||
}
|
||
|
||
/* ---- Chart widget config ---- */
|
||
type ChartTimeRange = '3m' | '6m' | '12m'
|
||
const chartTimeRange = ref<ChartTimeRange>('6m')
|
||
|
||
type ChartMetricId = 'gwp' | 'policies' | 'retention' | 'new-biz' | 'loss-ratio' | 'commission'
|
||
type ChartTypeId = 'line' | 'bar' | 'area'
|
||
|
||
interface ChartMetricOption {
|
||
id: ChartMetricId
|
||
label: string
|
||
data6m: { m: string; v: number; display: string }[]
|
||
change: string
|
||
changeTone: 'positive' | 'negative' | 'neutral'
|
||
}
|
||
|
||
const CHART_METRICS: ChartMetricOption[] = [
|
||
{ id: 'gwp', label: 'GWP written', change: '+6.2%', changeTone: 'positive',
|
||
data6m: [
|
||
{ m: 'Oct', v: 72, display: '$4.52M' }, { m: 'Nov', v: 68, display: '$4.28M' },
|
||
{ m: 'Dec', v: 76, display: '$4.71M' }, { m: 'Jan', v: 74, display: '$4.61M' },
|
||
{ m: 'Feb', v: 81, display: '$4.98M' }, { m: 'Mar', v: 88, display: '$5.41M' }
|
||
] },
|
||
{ id: 'policies', label: 'Policies bound', change: '+12', changeTone: 'positive',
|
||
data6m: [
|
||
{ m: 'Oct', v: 32, display: '32' }, { m: 'Nov', v: 28, display: '28' },
|
||
{ m: 'Dec', v: 35, display: '35' }, { m: 'Jan', v: 38, display: '38' },
|
||
{ m: 'Feb', v: 36, display: '36' }, { m: 'Mar', v: 42, display: '42' }
|
||
] },
|
||
{ id: 'retention', label: 'Retention rate', change: '+1.2%', changeTone: 'positive',
|
||
data6m: [
|
||
{ m: 'Oct', v: 88, display: '88%' }, { m: 'Nov', v: 87, display: '87%' },
|
||
{ m: 'Dec', v: 89, display: '89%' }, { m: 'Jan', v: 90, display: '90%' },
|
||
{ m: 'Feb', v: 90, display: '90%' }, { m: 'Mar', v: 91, display: '91%' }
|
||
] },
|
||
{ id: 'new-biz', label: 'New business', change: '+$820K', changeTone: 'positive',
|
||
data6m: [
|
||
{ m: 'Oct', v: 52, display: '$2.4M' }, { m: 'Nov', v: 48, display: '$2.2M' },
|
||
{ m: 'Dec', v: 62, display: '$2.9M' }, { m: 'Jan', v: 58, display: '$2.7M' },
|
||
{ m: 'Feb', v: 64, display: '$3.0M' }, { m: 'Mar', v: 70, display: '$3.2M' }
|
||
] },
|
||
{ id: 'loss-ratio', label: 'Loss ratio', change: '-3.1%', changeTone: 'positive',
|
||
data6m: [
|
||
{ m: 'Oct', v: 68, display: '68%' }, { m: 'Nov', v: 72, display: '72%' },
|
||
{ m: 'Dec', v: 65, display: '65%' }, { m: 'Jan', v: 63, display: '63%' },
|
||
{ m: 'Feb', v: 60, display: '60%' }, { m: 'Mar', v: 58, display: '58%' }
|
||
] },
|
||
{ id: 'commission', label: 'Commission rev', change: '+8.4%', changeTone: 'positive',
|
||
data6m: [
|
||
{ m: 'Oct', v: 55, display: '$680K' }, { m: 'Nov', v: 52, display: '$640K' },
|
||
{ m: 'Dec', v: 60, display: '$740K' }, { m: 'Jan', v: 58, display: '$715K' },
|
||
{ m: 'Feb', v: 65, display: '$800K' }, { m: 'Mar', v: 72, display: '$886K' }
|
||
] },
|
||
]
|
||
|
||
const activeChartMetric = ref<ChartMetricId>('gwp')
|
||
const activeChartType = ref<ChartTypeId>('area')
|
||
const chartMetricMenuOpen = ref(false)
|
||
|
||
const selectedChartMetric = computed(() =>
|
||
CHART_METRICS.find(m => m.id === activeChartMetric.value) ?? CHART_METRICS[0]!
|
||
)
|
||
|
||
const chartData = computed(() => {
|
||
const metric = selectedChartMetric.value
|
||
const range = chartTimeRange.value
|
||
if (range === '3m') return metric.data6m.slice(-3)
|
||
return metric.data6m // 6m default (we reuse for 12m with a label tweak)
|
||
})
|
||
|
||
const chartLatest = computed(() => chartData.value[chartData.value.length - 1]!)
|
||
|
||
/* Dynamic chart SVG model */
|
||
const chartSvgModel = computed(() => {
|
||
const pts = chartData.value.map(b => b.v)
|
||
const viewW = 400; const viewH = 152; const padX = 8; const padY = 14
|
||
const innerW = viewW - padX * 2; const innerH = viewH - padY * 2
|
||
const maxV = Math.max(...pts, 1); const minV = Math.min(...pts, 0)
|
||
const span = maxV - minV || 1
|
||
const points = pts.map((p, i) => ({
|
||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW,
|
||
y: padY + (1 - (p - minV) / span) * innerH, v: p
|
||
}))
|
||
const bottomY = padY + innerH
|
||
const first = points[0]!; const last = points[points.length - 1]!
|
||
|
||
// Line + area paths
|
||
let lineD = `M ${first.x},${first.y}`
|
||
let areaD = `M ${first.x},${bottomY} L ${first.x},${first.y}`
|
||
for (let i = 1; i < points.length; i++) {
|
||
const p0 = points[i - 1]!; const p1 = points[i]!
|
||
const cx = (p0.x + p1.x) / 2
|
||
const seg = ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||
lineD += seg; areaD += seg
|
||
}
|
||
areaD += ` L ${last.x},${bottomY} Z`
|
||
|
||
// Bar geometry
|
||
const barW = Math.min(innerW / pts.length * 0.6, 40)
|
||
const bars = pts.map((p, i) => ({
|
||
x: padX + (i / Math.max(1, pts.length - 1)) * innerW - barW / 2,
|
||
y: padY + (1 - (p - minV) / span) * innerH,
|
||
w: barW,
|
||
h: ((p - minV) / span) * innerH,
|
||
}))
|
||
|
||
const gridYs = [0, 0.5, 1].map(t => padY + t * innerH)
|
||
return { lineD, areaD, points, bars, gridYs, viewW, viewH, padX, innerW, bottomY }
|
||
})
|
||
|
||
type PipelineViewId = 'summary' | 'stage' | 'lob'
|
||
const pipelineView = ref<PipelineViewId>('summary')
|
||
const hoveredSegment = ref<number | null>(null)
|
||
|
||
const pipelineStages = [
|
||
{ label: 'Prospect', count: 24, value: '$2.1M', pct: 34, color: '#01696f' },
|
||
{ label: 'Quoted', count: 38, value: '$6.2M', pct: 100, color: '#1a8a8a' },
|
||
{ label: 'Negotiating', count: 12, value: '$3.8M', pct: 61, color: '#2dd4bf' },
|
||
{ label: 'Closing', count: 8, value: '$2.4M', pct: 39, color: '#0f7b5f' },
|
||
{ label: 'Won MTD', count: 14, value: '$1.8M', pct: 29, color: '#065f46' },
|
||
]
|
||
|
||
const pipelineLobBreakdown = [
|
||
{ label: 'Auto', count: 18, value: '$1.2M', pct: 19 },
|
||
{ label: 'Health', count: 12, value: '$3.4M', pct: 55 },
|
||
{ label: 'Life', count: 8, value: '$0.8M', pct: 13 },
|
||
{ label: 'Property', count: 6, value: '$0.5M', pct: 8 },
|
||
{ label: 'Other', count: 4, value: '$0.3M', pct: 5 },
|
||
]
|
||
|
||
const presetSelectItems = computed(() =>
|
||
DASHBOARD_PRESET_ORDER.map((id) => ({ label: DASHBOARD_ROLE_PRESETS[id].label, value: id }))
|
||
)
|
||
const activePresetHint = computed(() => DASHBOARD_ROLE_PRESETS[activePreset.value]?.hint ?? '')
|
||
|
||
watch(() => welcome.value.productName, (name) => {
|
||
if (typeof document !== 'undefined') document.title = name ? `Home \u00b7 ${name}` : 'Home'
|
||
}, { immediate: true })
|
||
|
||
/* ---- sparkline helpers ---- */
|
||
const kpiSparkSeries: Record<string, number[]> = {
|
||
ms: [42, 44, 41, 46, 48, 45, 49, 52, 50, 54, 53, 56],
|
||
mr: [58, 59, 57, 61, 62, 60, 63, 65, 64, 67, 66, 68],
|
||
ren: [72, 70, 74, 73, 75, 76, 74, 77, 78, 76, 79, 80],
|
||
late: [55, 52, 54, 50, 48, 47, 45, 44, 43, 42, 40, 38]
|
||
}
|
||
|
||
function smoothSparklinePath(points: number[], w = 112, h = 32, pad = 2) {
|
||
const max = Math.max(...points); const min = Math.min(...points); const r = max - min || 1
|
||
const pts = points.map((p, i, arr) => ({
|
||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||
y: pad + (1 - (p - min) / r) * (h - pad * 2)
|
||
}))
|
||
if (pts.length < 2) return ''
|
||
let d = `M ${pts[0]!.x},${pts[0]!.y}`
|
||
for (let i = 0; i < pts.length - 1; i++) {
|
||
const p0 = pts[i]!; const p1 = pts[i + 1]!; const cx = (p0.x + p1.x) / 2
|
||
d += ` C ${cx},${p0.y} ${cx},${p1.y} ${p1.x},${p1.y}`
|
||
}
|
||
return d
|
||
}
|
||
function smoothSparklineArea(points: number[], w = 112, h = 32, pad = 2) {
|
||
const path = smoothSparklinePath(points, w, h, pad)
|
||
if (!path) return ''
|
||
const pts = points.map((p, i, arr) => ({
|
||
x: pad + (i / Math.max(1, arr.length - 1)) * (w - pad * 2),
|
||
y: pad + (1 - (p - Math.min(...points)) / (Math.max(...points) - Math.min(...points) || 1)) * (h - pad * 2)
|
||
}))
|
||
return `${path} L ${pts[pts.length - 1]!.x},${h} L ${pts[0]!.x},${h} Z`
|
||
}
|
||
|
||
/* ---- Pipeline data ---- */
|
||
const QUOTED_PIPELINE_PREMIUM_M = 6.2
|
||
const quotedPipelineSummaryCards = [
|
||
{ label: 'Total book', value: '$42.8M', hint: 'In force' },
|
||
{ label: 'Quoted pipeline', value: '$6.2M', hint: 'Open quotes' },
|
||
{ label: 'YTD new sales', value: '$18.4M', hint: 'Bound new biz' }
|
||
] as const
|
||
const pipelineMixSegments = [
|
||
{ label: 'Commercial', pct: 38 }, { label: 'Personal', pct: 29 },
|
||
{ label: 'Benefits', pct: 22 }, { label: 'Other', pct: 11 }
|
||
] as const
|
||
const pipelineMixRows = computed(() =>
|
||
pipelineMixSegments.map((row) => ({ ...row, premiumM: (QUOTED_PIPELINE_PREMIUM_M * row.pct) / 100 }))
|
||
)
|
||
function formatPremiumM(n: number) { return `$${n.toFixed(2)}M` }
|
||
|
||
/* ---- Quick leads widget (last 10 days) ---- */
|
||
const { leads: quickLeadsList, recentLeads: recentQuickLeads } = useQuickLeads()
|
||
const dashQuickLeads = computed(() => recentQuickLeads(10).slice(0, 6))
|
||
|
||
/* ---- Client favorites widget ---- */
|
||
const { favoriteIds, removeFavorite: unfavClient } = useClientFavorites()
|
||
const _mockModule = ref<typeof import('~/data/mock-customers') | null>(null)
|
||
onMounted(async () => {
|
||
_mockModule.value = await import('~/data/mock-customers')
|
||
// Seed some favorites if empty
|
||
if (favoriteIds.value.length === 0 && _mockModule.value) {
|
||
const seedIds = _mockModule.value.MOCK_CUSTOMERS.filter(c => c.policies.length > 0).slice(0, 4).map(c => c.id)
|
||
favoriteIds.value = seedIds
|
||
}
|
||
})
|
||
|
||
const favCustomers = computed(() => {
|
||
const mod = _mockModule.value
|
||
if (!mod) return []
|
||
return favoriteIds.value
|
||
.map(id => mod.MOCK_CUSTOMERS.find(c => c.id === id))
|
||
.filter((c): c is InstanceType<any> => !!c)
|
||
.slice(0, 8)
|
||
})
|
||
|
||
function favTotalPremium(c: any) {
|
||
return (c.policies || []).reduce((s: number, p: any) => s + p.premium, 0)
|
||
}
|
||
|
||
function favTierLabel(c: any) {
|
||
const mod = _mockModule.value
|
||
if (!mod) return ''
|
||
const t = mod.customerTier(c)
|
||
if (t === 'customer') return 'Customer'
|
||
if (t === 'lead') return 'Lead'
|
||
if (t === 'quick_lead') return 'Quick Lead'
|
||
return 'Cancelled'
|
||
}
|
||
|
||
function favTierClass(c: any) {
|
||
const mod = _mockModule.value
|
||
if (!mod) return ''
|
||
const t = mod.customerTier(c)
|
||
if (t === 'customer') return 'h2-fav-tier-customer'
|
||
if (t === 'lead') return 'h2-fav-tier-lead'
|
||
if (t === 'quick_lead') return 'h2-fav-tier-ql'
|
||
return 'h2-fav-tier-cancelled'
|
||
}
|
||
|
||
function favFmtMoney(n: number) {
|
||
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 0 })
|
||
}
|
||
|
||
function qlPriorityMeta(p: string) {
|
||
if (p === 'urgent') return { label: 'Urgent', cls: 'h2-ql-pri-urgent' }
|
||
if (p === 'high') return { label: 'High', cls: 'h2-ql-pri-high' }
|
||
return { label: 'Normal', cls: 'h2-ql-pri-normal' }
|
||
}
|
||
|
||
function qlTimeAgo(iso: string) {
|
||
const diff = Date.now() - new Date(iso).getTime()
|
||
if (diff < 3600000) return `${Math.max(1, Math.round(diff / 60000))}m ago`
|
||
if (diff < 86400000) return `${Math.round(diff / 3600000)}h ago`
|
||
if (diff < 172800000) return 'Yesterday'
|
||
const d = new Date(iso)
|
||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||
}
|
||
|
||
/* ---- Sales Leads widget ---- */
|
||
type SalesLeadSource = 'walk_in' | 'instagram' | 'facebook' | 'google' | 'referral' | 'website' | 'phone' | 'campaign'
|
||
type SalesLead = {
|
||
id: string
|
||
name: string
|
||
email?: string
|
||
phone?: string
|
||
source: SalesLeadSource
|
||
campaignName?: string
|
||
lob: string
|
||
status: 'new' | 'contacted' | 'qualified' | 'proposal' | 'won' | 'lost'
|
||
assignedTo: string
|
||
createdAt: string
|
||
value?: number
|
||
note?: string
|
||
}
|
||
|
||
const SALES_LEAD_SOURCES: { value: SalesLeadSource | 'all'; label: string; icon: string }[] = [
|
||
{ value: 'all', label: 'All sources', icon: 'i-heroicons-funnel' },
|
||
{ value: 'walk_in', label: 'Walk-in', icon: 'i-heroicons-building-storefront' },
|
||
{ value: 'instagram', label: 'Instagram', icon: 'i-heroicons-camera' },
|
||
{ value: 'facebook', label: 'Facebook', icon: 'i-heroicons-chat-bubble-left-right' },
|
||
{ value: 'google', label: 'Google', icon: 'i-heroicons-magnifying-glass' },
|
||
{ value: 'referral', label: 'Referral', icon: 'i-heroicons-user-group' },
|
||
{ value: 'website', label: 'Website', icon: 'i-heroicons-globe-alt' },
|
||
{ value: 'phone', label: 'Phone', icon: 'i-heroicons-phone' },
|
||
{ value: 'campaign', label: 'Campaigns', icon: 'i-heroicons-megaphone' }
|
||
]
|
||
|
||
const MOCK_SALES_LEADS: SalesLead[] = [
|
||
{ id: 'sl-1', name: 'Ana García', email: 'ana.g@mail.com', phone: '+506 7012-3344', source: 'instagram', lob: 'Auto', status: 'new', assignedTo: 'L. Chen', createdAt: '2026-04-07T14:30:00Z', value: 2400, note: 'DM about fleet coverage' },
|
||
{ id: 'sl-2', name: 'Marco Rodríguez', phone: '+506 8876-5500', source: 'walk_in', lob: 'Health', status: 'contacted', assignedTo: 'A. Morales', createdAt: '2026-04-07T09:15:00Z', value: 4800, note: 'Family plan, 4 members' },
|
||
{ id: 'sl-3', name: 'Construcciones TRC', email: 'info@trc.cr', source: 'google', lob: 'General risk', status: 'qualified', assignedTo: 'R. Vega', createdAt: '2026-04-06T16:00:00Z', value: 18500, campaignName: 'Google Ads — Commercial Q2' },
|
||
{ id: 'sl-4', name: 'Isabel Mora', email: 'isa.mora@outlook.com', source: 'facebook', lob: 'Life', status: 'new', assignedTo: 'L. Chen', createdAt: '2026-04-06T11:20:00Z', value: 3200, note: 'Responded to FB carousel ad' },
|
||
{ id: 'sl-5', name: 'Hotel Paraíso', phone: '+506 2234-8800', source: 'referral', lob: 'General risk', status: 'proposal', assignedTo: 'A. Morales', createdAt: '2026-04-05T10:00:00Z', value: 12000, note: 'Referred by Transportes Delta' },
|
||
{ id: 'sl-6', name: 'Laura Jiménez', email: 'laura.j@gmail.com', source: 'website', lob: 'Auto', status: 'contacted', assignedTo: 'R. Vega', createdAt: '2026-04-05T08:45:00Z', value: 1800, note: 'Online form — comparativo request' },
|
||
{ id: 'sl-7', name: 'Farmacia Salud Plus', phone: '+506 2456-7890', source: 'campaign', campaignName: 'Spring Health Push', lob: 'Health', status: 'qualified', assignedTo: 'A. Morales', createdAt: '2026-04-04T15:30:00Z', value: 9600 },
|
||
{ id: 'sl-8', name: 'Diego Araya', source: 'phone', phone: '+506 6100-9988', lob: 'Auto', status: 'new', assignedTo: 'L. Chen', createdAt: '2026-04-04T12:10:00Z', value: 1500 },
|
||
{ id: 'sl-9', name: 'Retail Express', email: 'ventas@retailexp.cr', source: 'campaign', campaignName: 'Commercial Outreach Mar', lob: 'General risk', status: 'won', assignedTo: 'R. Vega', createdAt: '2026-04-03T09:00:00Z', value: 7200 },
|
||
{ id: 'sl-10', name: 'Sofía Vargas', source: 'instagram', phone: '+506 8300-1122', lob: 'Life', status: 'contacted', assignedTo: 'L. Chen', createdAt: '2026-04-02T17:00:00Z', value: 2800, note: 'IG story reply, wants term quote' }
|
||
]
|
||
|
||
const slSourceFilter = ref<SalesLeadSource | 'all'>('all')
|
||
const slStatusFilter = ref<string>('all')
|
||
|
||
const filteredSalesLeads = computed(() => {
|
||
let leads = MOCK_SALES_LEADS
|
||
if (slSourceFilter.value !== 'all') {
|
||
leads = leads.filter(l => l.source === slSourceFilter.value)
|
||
}
|
||
if (slStatusFilter.value !== 'all') {
|
||
leads = leads.filter(l => l.status === slStatusFilter.value)
|
||
}
|
||
return leads
|
||
})
|
||
|
||
const slSourceCounts = computed(() => {
|
||
const counts: Record<string, number> = { all: MOCK_SALES_LEADS.length }
|
||
for (const l of MOCK_SALES_LEADS) {
|
||
counts[l.source] = (counts[l.source] || 0) + 1
|
||
}
|
||
return counts
|
||
})
|
||
|
||
function slStatusBadge(s: SalesLead['status']) {
|
||
switch (s) {
|
||
case 'new': return { label: 'New', cls: 'h2-sl-status-new' }
|
||
case 'contacted': return { label: 'Contacted', cls: 'h2-sl-status-contacted' }
|
||
case 'qualified': return { label: 'Qualified', cls: 'h2-sl-status-qualified' }
|
||
case 'proposal': return { label: 'Proposal', cls: 'h2-sl-status-proposal' }
|
||
case 'won': return { label: 'Won', cls: 'h2-sl-status-won' }
|
||
case 'lost': return { label: 'Lost', cls: 'h2-sl-status-lost' }
|
||
}
|
||
}
|
||
|
||
function slSourceLabel(s: SalesLeadSource) {
|
||
return SALES_LEAD_SOURCES.find(x => x.value === s)?.label ?? s
|
||
}
|
||
|
||
function slSourceIcon(s: SalesLeadSource) {
|
||
return SALES_LEAD_SOURCES.find(x => x.value === s)?.icon ?? 'i-heroicons-question-mark-circle'
|
||
}
|
||
|
||
function slFmtValue(v?: number) {
|
||
if (!v) return ''
|
||
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0 })
|
||
}
|
||
|
||
/* ---- Tone helpers ---- */
|
||
function changeToneClass(tone: WelcomeDashboardKpi['changeTone']) {
|
||
switch (tone) {
|
||
case 'positive': return 'h2-tone-pos'
|
||
case 'negative': return 'h2-tone-neg'
|
||
default: return 'h2-tone-neutral'
|
||
}
|
||
}
|
||
|
||
type AlertToneMeta = { icon: string; label: string; railStyle: string; iconColor: string; bg: string }
|
||
function alertToneMeta(tone: string): AlertToneMeta {
|
||
switch (tone) {
|
||
case 'error': return { icon: 'i-heroicons-exclamation-circle', label: 'Critical', railStyle: 'background:#c13838', iconColor: 'text-rose-600', bg: 'bg-rose-50/60' }
|
||
case 'warning': return { icon: 'i-heroicons-exclamation-triangle', label: 'Attention', railStyle: 'background:#c27b1a', iconColor: 'text-amber-600', bg: 'bg-amber-50/60' }
|
||
case 'success': return { icon: 'i-heroicons-check-circle', label: 'Update', railStyle: 'background:#0f7b5f', iconColor: 'text-emerald-700', bg: 'bg-emerald-50/60' }
|
||
default: return { icon: 'i-heroicons-information-circle', label: 'Notice', railStyle: 'background:#8c857d', iconColor: 'text-stone-500', bg: 'bg-stone-100/60' }
|
||
}
|
||
}
|
||
const alertsWithMeta = computed(() => welcome.value.alerts.map((a) => ({ ...a, meta: alertToneMeta(a.tone) })))
|
||
|
||
/* ---- Operations command bar data ---- */
|
||
type OpsStatus = 'on-track' | 'attention' | 'warning' | 'neutral'
|
||
type OpsMetric = { id: string; label: string; value: string; target: string; status: OpsStatus; icon: string }
|
||
|
||
const ALL_OPS_INDICATORS: OpsMetric[] = [
|
||
{ id: 'prod', label: 'Production MTD', value: '$1.24M', target: '$1.18M', status: 'on-track', icon: 'i-heroicons-banknotes' },
|
||
{ id: 'ren', label: 'Renewals due', value: '23', target: 'next 30d', status: 'attention', icon: 'i-heroicons-arrow-path' },
|
||
{ id: 'coll', label: 'Collections at risk', value: '$184K', target: '3 accounts', status: 'warning', icon: 'i-heroicons-exclamation-triangle' },
|
||
{ id: 'claims', label: 'Claims pending', value: '7', target: 'avg 4.2d open', status: 'neutral', icon: 'i-heroicons-shield-exclamation' },
|
||
{ id: 'svc', label: 'Service backlog', value: '12', target: 'SLA: 94%', status: 'on-track', icon: 'i-heroicons-inbox-stack' },
|
||
// Additional indicators available in catalog
|
||
{ id: 'gwp', label: 'GWP YTD', value: '$18.4M', target: '$17.2M plan', status: 'on-track', icon: 'i-heroicons-chart-bar' },
|
||
{ id: 'nps', label: 'NPS score', value: '72', target: '> 65 target', status: 'on-track', icon: 'i-heroicons-face-smile' },
|
||
{ id: 'quotes', label: 'Open quotes', value: '38', target: '$6.2M pipeline', status: 'attention', icon: 'i-heroicons-document-text' },
|
||
{ id: 'retention', label: 'Retention rate', value: '91%', target: 'trailing 12m', status: 'on-track', icon: 'i-heroicons-arrow-path-rounded-square' },
|
||
{ id: 'ar', label: 'AR aging 60d+', value: '$42K', target: '5 accounts', status: 'warning', icon: 'i-heroicons-clock' },
|
||
{ id: 'cancels', label: 'Cancellations MTD', value: '3', target: '$28K premium', status: 'warning', icon: 'i-heroicons-x-circle' },
|
||
{ id: 'newbiz', label: 'New business MTD', value: '14', target: '$412K bound', status: 'on-track', icon: 'i-heroicons-sparkles' },
|
||
]
|
||
|
||
const OPS_STORAGE_KEY = 'policy-ui.dashboard.ops-indicators'
|
||
const DEFAULT_IDS = ['prod', 'ren', 'coll', 'claims', 'svc']
|
||
|
||
const activeOpsIds = ref<string[]>([...DEFAULT_IDS])
|
||
|
||
onMounted(() => {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try {
|
||
const raw = localStorage.getItem(OPS_STORAGE_KEY)
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw)
|
||
if (Array.isArray(parsed) && parsed.length > 0) activeOpsIds.value = parsed
|
||
}
|
||
} catch { /* ignore */ }
|
||
}
|
||
})
|
||
|
||
function persistOpsIds() {
|
||
if (typeof localStorage !== 'undefined') {
|
||
try { localStorage.setItem(OPS_STORAGE_KEY, JSON.stringify(activeOpsIds.value)) } catch { /* */ }
|
||
}
|
||
}
|
||
|
||
const opsMetrics = computed(() =>
|
||
activeOpsIds.value
|
||
.map((id) => ALL_OPS_INDICATORS.find((m) => m.id === id))
|
||
.filter((m): m is OpsMetric => !!m)
|
||
)
|
||
|
||
const opsConfigOpen = ref(false)
|
||
const opsRailHover = ref(false)
|
||
const opsConfigRef = ref<HTMLElement | null>(null)
|
||
|
||
function toggleOpsIndicator(id: string) {
|
||
const idx = activeOpsIds.value.indexOf(id)
|
||
if (idx >= 0) {
|
||
if (activeOpsIds.value.length <= 2) return // keep at least 2
|
||
activeOpsIds.value = activeOpsIds.value.filter((x) => x !== id)
|
||
} else {
|
||
if (activeOpsIds.value.length >= 7) return // max 7
|
||
activeOpsIds.value = [...activeOpsIds.value, id]
|
||
}
|
||
persistOpsIds()
|
||
}
|
||
|
||
function resetOpsIndicators() {
|
||
activeOpsIds.value = [...DEFAULT_IDS]
|
||
persistOpsIds()
|
||
}
|
||
|
||
function onOpsConfigClickOutside(e: MouseEvent) {
|
||
const el = opsConfigRef.value
|
||
if (el && opsConfigOpen.value && !el.contains(e.target as Node)) {
|
||
opsConfigOpen.value = false
|
||
}
|
||
// Close milestone config on outside click
|
||
if (milestoneConfigOpen.value && !milestoneConfigSkipClose) {
|
||
const target = e.target as HTMLElement
|
||
if (!target.closest('.h2-mile-popover') && !target.closest('.h2-mile-config-wrap')) {
|
||
milestoneConfigOpen.value = false
|
||
}
|
||
}
|
||
// Close chart metric menu on outside click
|
||
if (chartMetricMenuOpen.value) {
|
||
const target = e.target as HTMLElement
|
||
if (!target.closest('.h2-chart-metric-menu') && !target.closest('.h2-chart-metric-btn')) {
|
||
chartMetricMenuOpen.value = false
|
||
}
|
||
}
|
||
}
|
||
onMounted(() => document.addEventListener('click', onOpsConfigClickOutside))
|
||
onUnmounted(() => document.removeEventListener('click', onOpsConfigClickOutside))
|
||
|
||
function opsStatusDotStyle(status: OpsStatus) {
|
||
switch (status) {
|
||
case 'on-track': return 'background:#0f7b5f'
|
||
case 'attention': return 'background:#0d5c63'
|
||
case 'warning': return 'background:#c27b1a'
|
||
default: return 'background:#8c857d'
|
||
}
|
||
}
|
||
|
||
function opsCellIconStyle(status: OpsStatus) {
|
||
switch (status) {
|
||
case 'on-track': return 'background: rgba(15,123,95,0.08); color: #0f7b5f'
|
||
case 'attention': return 'background: rgba(1,105,111,0.08); color: #01696f'
|
||
case 'warning': return 'background: rgba(194,123,26,0.08); color: #c27b1a'
|
||
default: return 'background: rgba(0,0,0,0.04); color: #8a8a86'
|
||
}
|
||
}
|
||
|
||
function opsGridCols() {
|
||
const n = opsMetrics.value.length
|
||
if (n <= 3) return 'grid-cols-2 sm:grid-cols-3'
|
||
if (n <= 4) return 'grid-cols-2 sm:grid-cols-4'
|
||
if (n <= 5) return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5'
|
||
if (n <= 6) return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6'
|
||
return 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-7'
|
||
}
|
||
|
||
/* ---- Sent quotes list (dashboard widget) ---- */
|
||
type SentQuote = {
|
||
id: string
|
||
customer: string
|
||
party: 'corporate' | 'personal'
|
||
lob: string
|
||
lobIcon: string
|
||
premium: number
|
||
commission: number
|
||
carrier: string
|
||
sentDate: string
|
||
status: 'sent' | 'viewed' | 'accepted' | 'expired' | 'declined'
|
||
agent: string
|
||
}
|
||
|
||
const SENT_QUOTES: SentQuote[] = [
|
||
{ id: 'QT-2025-0088', customer: 'Transportes Delta S.A.', party: 'corporate', lob: 'Auto', lobIcon: 'i-heroicons-truck', premium: 18400, commission: 2760, carrier: 'ASSA', sentDate: '2025-04-04', status: 'sent', agent: 'Ana R.' },
|
||
{ id: 'QT-2025-0087', customer: 'María Elena Pérez', party: 'personal', lob: 'Health', lobIcon: 'i-heroicons-heart', premium: 3200, commission: 480, carrier: 'INS', sentDate: '2025-04-03', status: 'viewed', agent: 'Ana R.' },
|
||
{ id: 'QT-2025-0086', customer: 'Holdings Centro', party: 'corporate', lob: 'Life', lobIcon: 'i-heroicons-shield-check', premium: 42000, commission: 5040, carrier: 'Pan-American Life', sentDate: '2025-04-02', status: 'accepted', agent: 'Marco V.' },
|
||
{ id: 'QT-2025-0085', customer: 'Roberto Jiménez Mora', party: 'personal', lob: 'Auto', lobIcon: 'i-heroicons-truck', premium: 1520, commission: 228, carrier: 'Qualitas', sentDate: '2025-04-01', status: 'sent', agent: 'Ana R.' },
|
||
{ id: 'QT-2025-0084', customer: 'Clínica Norte', party: 'corporate', lob: 'Health', lobIcon: 'i-heroicons-heart', premium: 86000, commission: 10320, carrier: 'Blue Cross', sentDate: '2025-03-31', status: 'accepted', agent: 'Marco V.' },
|
||
{ id: 'QT-2025-0083', customer: 'Sofía Campos Rojas', party: 'personal', lob: 'Auto', lobIcon: 'i-heroicons-truck', premium: 1380, commission: 207, carrier: 'INS', sentDate: '2025-03-30', status: 'expired', agent: 'Marco V.' },
|
||
{ id: 'QT-2025-0082', customer: 'Retail Plaza', party: 'corporate', lob: 'General Risk', lobIcon: 'i-heroicons-building-office-2', premium: 24600, commission: 3690, carrier: 'ASSA', sentDate: '2025-03-28', status: 'declined', agent: 'Ana R.' },
|
||
{ id: 'QT-2025-0081', customer: 'Carolina Fallas Vargas', party: 'personal', lob: 'Renter', lobIcon: 'i-heroicons-home-modern', premium: 320, commission: 48, carrier: 'ASSA', sentDate: '2025-03-27', status: 'accepted', agent: 'Marco V.' },
|
||
{ id: 'QT-2025-0080', customer: 'Luis Andrés Solís', party: 'personal', lob: 'Life', lobIcon: 'i-heroicons-shield-check', premium: 4800, commission: 576, carrier: 'Pan-American Life', sentDate: '2025-03-25', status: 'viewed', agent: 'Ana R.' },
|
||
{ id: 'QT-2025-0079', customer: 'Startup Labs', party: 'corporate', lob: 'Health', lobIcon: 'i-heroicons-heart', premium: 12800, commission: 1536, carrier: 'INS', sentDate: '2025-03-22', status: 'sent', agent: 'Marco V.' },
|
||
]
|
||
|
||
type QuoteSortKey = 'date-new' | 'date-old' | 'premium-high' | 'premium-low' | 'commission-high' | 'commission-low'
|
||
const quoteSortKey = ref<QuoteSortKey>('date-new')
|
||
const quoteFilterParty = ref<'all' | 'corporate' | 'personal'>('all')
|
||
const quoteFilterStatus = ref<string>('all')
|
||
const quoteFilterLob = ref<string>('all')
|
||
|
||
const quoteSortOptions = [
|
||
{ label: 'Newest first', value: 'date-new' as const },
|
||
{ label: 'Oldest first', value: 'date-old' as const },
|
||
{ label: 'Premium ↓', value: 'premium-high' as const },
|
||
{ label: 'Premium ↑', value: 'premium-low' as const },
|
||
{ label: 'Commission ↓', value: 'commission-high' as const },
|
||
{ label: 'Commission ↑', value: 'commission-low' as const },
|
||
]
|
||
const quotePartyOptions = [
|
||
{ label: 'All', value: 'all' },
|
||
{ label: 'Corporate', value: 'corporate' },
|
||
{ label: 'Personal', value: 'personal' },
|
||
]
|
||
const quoteStatusOptions = [
|
||
{ label: 'All statuses', value: 'all' },
|
||
{ label: 'Sent', value: 'sent' },
|
||
{ label: 'Viewed', value: 'viewed' },
|
||
{ label: 'Accepted', value: 'accepted' },
|
||
{ label: 'Expired', value: 'expired' },
|
||
{ label: 'Declined', value: 'declined' },
|
||
]
|
||
const quoteLobOptions = computed(() => {
|
||
const lobs = [...new Set(SENT_QUOTES.map(q => q.lob))]
|
||
return [{ label: 'All lines', value: 'all' }, ...lobs.map(l => ({ label: l, value: l }))]
|
||
})
|
||
|
||
const filteredSentQuotes = computed(() => {
|
||
let list = [...SENT_QUOTES]
|
||
|
||
if (quoteFilterParty.value !== 'all') list = list.filter(q => q.party === quoteFilterParty.value)
|
||
if (quoteFilterStatus.value !== 'all') list = list.filter(q => q.status === quoteFilterStatus.value)
|
||
if (quoteFilterLob.value !== 'all') list = list.filter(q => q.lob === quoteFilterLob.value)
|
||
|
||
switch (quoteSortKey.value) {
|
||
case 'date-new': list.sort((a, b) => b.sentDate.localeCompare(a.sentDate)); break
|
||
case 'date-old': list.sort((a, b) => a.sentDate.localeCompare(b.sentDate)); break
|
||
case 'premium-high': list.sort((a, b) => b.premium - a.premium); break
|
||
case 'premium-low': list.sort((a, b) => a.premium - b.premium); break
|
||
case 'commission-high': list.sort((a, b) => b.commission - a.commission); break
|
||
case 'commission-low': list.sort((a, b) => a.commission - b.commission); break
|
||
}
|
||
|
||
return list
|
||
})
|
||
|
||
const quoteStatusMeta: Record<string, { label: string; cls: string }> = {
|
||
sent: { label: 'Sent', cls: 'h2-qs-sent' },
|
||
viewed: { label: 'Viewed', cls: 'h2-qs-viewed' },
|
||
accepted: { label: 'Accepted', cls: 'h2-qs-accepted' },
|
||
expired: { label: 'Expired', cls: 'h2-qs-expired' },
|
||
declined: { label: 'Declined', cls: 'h2-qs-declined' },
|
||
}
|
||
|
||
/* ---- Time ---- */
|
||
const timeGreeting = computed(() => {
|
||
const h = new Date().getHours()
|
||
if (h < 12) return 'Good morning'
|
||
if (h < 17) return 'Good afternoon'
|
||
return 'Good evening'
|
||
})
|
||
const currentDate = computed(() =>
|
||
new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'long', day: 'numeric' }).format(new Date())
|
||
)
|
||
|
||
/* ---- Calendar widget ---- */
|
||
type CalEventType = 'meeting' | 'renewal' | 'claim' | 'payment' | 'reminder' | 'threshold'
|
||
|
||
interface CalEvent {
|
||
id: string
|
||
title: string
|
||
time: string
|
||
type: CalEventType
|
||
source?: 'gmail' | 'system' | 'manual'
|
||
customer?: string
|
||
detail?: string
|
||
urgent?: boolean
|
||
}
|
||
|
||
const calEventTypeMeta: Record<CalEventType, { label: string; color: string; icon: string }> = {
|
||
meeting: { label: 'Meetings', color: '#01696f', icon: 'i-heroicons-calendar-days' },
|
||
renewal: { label: 'Renewals', color: '#7c3aed', icon: 'i-heroicons-arrow-path' },
|
||
claim: { label: 'Claims', color: '#c13838', icon: 'i-heroicons-shield-exclamation' },
|
||
payment: { label: 'Payments', color: '#c27b1a', icon: 'i-heroicons-banknotes' },
|
||
reminder: { label: 'Reminders', color: '#0f7b5f', icon: 'i-heroicons-bell' },
|
||
threshold: { label: 'Alerts', color: '#be185d', icon: 'i-heroicons-exclamation-triangle' },
|
||
}
|
||
|
||
const todayEvents: CalEvent[] = [
|
||
{ id: 'c1', title: 'Renewal review — Constructora Delta', time: '9:00 AM', type: 'renewal', source: 'system', customer: 'Constructora Delta S.A.', detail: 'Policy expires Jul 15. Premium $55K, 3 yr client.', urgent: true },
|
||
{ id: 'c2', title: 'Call with María Pérez', time: '10:30 AM', type: 'meeting', source: 'gmail', customer: 'María Pérez', detail: 'Auto quote follow-up. Synced from Gmail.' },
|
||
{ id: 'c3', title: 'Unpaid premium — Farmacia Salud', time: '11:00 AM', type: 'payment', source: 'system', customer: 'Farmacia Salud', detail: '45 days overdue. $12,800 group life.', urgent: true },
|
||
{ id: 'c4', title: 'Claim status update — Hotel Pacífico', time: '1:00 PM', type: 'claim', source: 'system', customer: 'Hotel Pacífico', detail: 'Fire claim #CL-2891. Adjuster report due.' },
|
||
{ id: 'c5', title: 'Client crossed $100K premium', time: '2:00 PM', type: 'threshold', source: 'system', customer: 'Banco Regional', detail: 'Total book now $186K. VIP follow-up recommended.' },
|
||
{ id: 'c6', title: 'Prepare renewal proposal', time: '3:30 PM', type: 'reminder', source: 'manual', detail: 'Transportes del Sur fleet policy. Get 3 comparative quotes.' },
|
||
{ id: 'c7', title: 'Team sync — weekly pipeline', time: '4:00 PM', type: 'meeting', source: 'gmail', detail: 'Google Meet link attached. All agents.' },
|
||
{ id: 'c8', title: '5 renewals due next 7 days', time: '—', type: 'renewal', source: 'system', detail: 'Batch alert: review upcoming expirations.', urgent: true },
|
||
]
|
||
|
||
const filteredCalEvents = computed(() => todayEvents)
|
||
|
||
function calEventDotStyle(type: CalEventType) {
|
||
return `background: ${calEventTypeMeta[type].color}`
|
||
}
|
||
|
||
/* ---- Segment colors (petroleum palette) ---- */
|
||
/* Segment colors as inline styles (Tailwind v4 doesn't resolve teal) */
|
||
const segColorStyles = ['background:#0d5c63', 'background:#1a8a8a', 'background:#2dd4bf', 'background:#a8a29e']
|
||
const segDotStyles = ['background:#0d5c63;outline:2px solid rgba(13,92,99,0.2);outline-offset:1px', 'background:#1a8a8a;outline:2px solid rgba(26,138,138,0.2);outline-offset:1px', 'background:#2dd4bf;outline:2px solid rgba(45,212,191,0.2);outline-offset:1px', 'background:#a8a29e;outline:2px solid rgba(168,162,158,0.2);outline-offset:1px']
|
||
|
||
/* ---- Drafts detection ---- */
|
||
interface DraftEntry {
|
||
key: string
|
||
label: string
|
||
icon: string
|
||
route: string
|
||
age: string
|
||
}
|
||
|
||
const DRAFT_SOURCES = [
|
||
{ key: 'policy-registration-draft-v1', label: 'Policy registration', icon: 'i-heroicons-document-text', route: '/registration/policy' },
|
||
{ key: 'policy-ui-quote-session-v1', label: 'Quote comparison', icon: 'i-heroicons-document-magnifying-glass', route: '/quotes/compare' },
|
||
{ key: 'policy-ui-customer-profile-vault-v1', label: 'Customer profile', icon: 'i-heroicons-user-circle', route: '/onboarding/solicitud' },
|
||
]
|
||
|
||
const activeDrafts = ref<DraftEntry[]>([])
|
||
|
||
function draftAge(raw: any): string {
|
||
const ts = raw?.updatedAt || raw?.touchedAt || raw?.createdAt
|
||
if (!ts || typeof ts !== 'number') return 'Unknown'
|
||
const mins = Math.floor((Date.now() - ts) / 60000)
|
||
if (mins < 1) return 'Just now'
|
||
if (mins < 60) return `${mins}m ago`
|
||
const hrs = Math.floor(mins / 60)
|
||
if (hrs < 24) return `${hrs}h ago`
|
||
const days = Math.floor(hrs / 24)
|
||
return `${days}d ago`
|
||
}
|
||
|
||
onMounted(() => {
|
||
if (typeof localStorage === 'undefined') return
|
||
const found: DraftEntry[] = []
|
||
for (const src of DRAFT_SOURCES) {
|
||
try {
|
||
const raw = localStorage.getItem(src.key)
|
||
if (raw) {
|
||
const parsed = JSON.parse(raw)
|
||
if (parsed && typeof parsed === 'object' && Object.keys(parsed).length > 0) {
|
||
found.push({ ...src, age: draftAge(parsed) })
|
||
}
|
||
}
|
||
} catch { /* ignore corrupt entries */ }
|
||
}
|
||
activeDrafts.value = found
|
||
})
|
||
|
||
function removeDraft(key: string) {
|
||
if (typeof localStorage !== 'undefined') {
|
||
localStorage.removeItem(key)
|
||
}
|
||
activeDrafts.value = activeDrafts.value.filter(d => d.key !== key)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h2 relative min-h-full pb-12">
|
||
|
||
<HomeDashboardWidgetBlocks
|
||
:widget-order="widgetOrder"
|
||
:widgets="widgets"
|
||
:layout-unlocked="layoutUnlocked"
|
||
:dragging-widget="draggingWidget"
|
||
@drag-start="onDragStart"
|
||
@drag-end="onDragEnd"
|
||
@drop="onDropSection"
|
||
>
|
||
<!-- ==================== HERO: Operations Command Bar ==================== -->
|
||
<template #hero>
|
||
<div class="space-y-4">
|
||
<!-- Slim greeting strip -->
|
||
<div class="flex flex-wrap items-center justify-between gap-3 px-1">
|
||
<div class="flex items-baseline gap-3">
|
||
<h1 class="text-xl font-semibold tracking-tight text-[var(--h2-fg)]">
|
||
{{ timeGreeting }}, {{ welcome.greetingName }}
|
||
</h1>
|
||
<span class="text-xs text-[var(--h2-muted)]">{{ currentDate }}</span>
|
||
</div>
|
||
<!-- Mobile-only: action buttons (on desktop these live in the topbar) -->
|
||
<div class="flex gap-2 sm:hidden">
|
||
<NuxtLink to="/onboarding">
|
||
<UButton size="sm" color="neutral" variant="outline" class="h2-btn-outline" icon="i-heroicons-arrow-trending-up">
|
||
Pipeline
|
||
</UButton>
|
||
</NuxtLink>
|
||
<NuxtLink to="/sales/quick-lead">
|
||
<UButton size="sm" color="neutral" variant="soft" class="h2-btn-outline" icon="i-heroicons-bolt">
|
||
Quick Lead
|
||
</UButton>
|
||
</NuxtLink>
|
||
<NuxtLink to="/quotes">
|
||
<UButton size="sm" color="primary" class="h2-btn-primary" icon="i-heroicons-document-text">
|
||
New quote
|
||
</UButton>
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Operations rail: configurable indicator strip -->
|
||
<div
|
||
class="h2-ops-rail-wrap relative"
|
||
@mouseenter="opsRailHover = true"
|
||
@mouseleave="opsRailHover = false"
|
||
>
|
||
<div class="h2-card h2-card-flush grid" :class="opsGridCols()">
|
||
<div
|
||
v-for="(op, i) in opsMetrics"
|
||
:key="op.id"
|
||
class="h2-ops-cell"
|
||
:class="[
|
||
i < opsMetrics.length - 1 ? 'h2-cell-border' : '',
|
||
i === 0 ? 'h2-ops-cell--lead' : ''
|
||
]"
|
||
>
|
||
<div class="h2-ops-cell-icon" :style="opsCellIconStyle(op.status)">
|
||
<UIcon :name="op.icon" style="width: 15px; height: 15px;" />
|
||
</div>
|
||
<div class="h2-ops-cell-body">
|
||
<p class="h2-ops-cell-label">{{ op.label }}</p>
|
||
<p class="h2-ops-cell-value">{{ op.value }}</p>
|
||
<div class="h2-ops-cell-target">
|
||
<span class="h2-ops-dot" :style="opsStatusDotStyle(op.status)" />
|
||
<span>{{ op.target }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Widget config button — appears on hover, bottom-right corner -->
|
||
<Transition
|
||
enter-active-class="transition duration-150 ease-out"
|
||
enter-from-class="opacity-0 scale-90"
|
||
enter-to-class="opacity-100 scale-100"
|
||
leave-active-class="transition duration-100 ease-in"
|
||
leave-from-class="opacity-100"
|
||
leave-to-class="opacity-0 scale-90"
|
||
>
|
||
<div
|
||
v-show="opsRailHover || opsConfigOpen"
|
||
ref="opsConfigRef"
|
||
class="absolute -bottom-2 -right-2 z-20"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="h2-ops-config-btn"
|
||
title="Configure indicators"
|
||
@click.stop="opsConfigOpen = !opsConfigOpen"
|
||
>
|
||
<UIcon name="i-heroicons-squares-plus" style="width: 14px; height: 14px;" />
|
||
</button>
|
||
|
||
<!-- Config popover -->
|
||
<Transition
|
||
enter-active-class="transition duration-150 ease-out"
|
||
enter-from-class="opacity-0 scale-95 translate-y-1"
|
||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||
leave-active-class="transition duration-100 ease-in"
|
||
leave-from-class="opacity-100"
|
||
leave-to-class="opacity-0 scale-95"
|
||
>
|
||
<div v-show="opsConfigOpen" class="h2-ops-config-popover">
|
||
<div class="h2-ops-config-head">
|
||
<span>Indicators</span>
|
||
<button type="button" class="h2-ops-config-reset" @click="resetOpsIndicators">Reset</button>
|
||
</div>
|
||
<div class="h2-ops-config-hint">
|
||
Select 2–7 KPIs for your command strip.
|
||
</div>
|
||
<ul class="h2-ops-config-list">
|
||
<li
|
||
v-for="ind in ALL_OPS_INDICATORS"
|
||
:key="ind.id"
|
||
class="h2-ops-config-item"
|
||
:class="activeOpsIds.includes(ind.id) ? 'h2-ops-config-item-on' : ''"
|
||
@click="toggleOpsIndicator(ind.id)"
|
||
>
|
||
<div class="h2-ops-config-check">
|
||
<UIcon
|
||
v-if="activeOpsIds.includes(ind.id)"
|
||
name="i-heroicons-check"
|
||
style="width: 12px; height: 12px;"
|
||
/>
|
||
</div>
|
||
<UIcon :name="ind.icon" style="width: 14px; height: 14px; opacity: 0.5;" />
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-[12px] font-medium text-[var(--text-primary)] truncate">{{ ind.label }}</p>
|
||
<p class="text-[10px] text-[var(--text-muted)]">{{ ind.value }} · {{ ind.target }}</p>
|
||
</div>
|
||
<span
|
||
class="h2-ops-config-dot"
|
||
:style="opsStatusDotStyle(ind.status)"
|
||
/>
|
||
</li>
|
||
</ul>
|
||
<div class="h2-ops-config-footer">
|
||
{{ activeOpsIds.length }} of {{ ALL_OPS_INDICATORS.length }} selected
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ==================== MILESTONE ==================== -->
|
||
<template #milestone>
|
||
<div class="h2-milestone-card relative group/mile">
|
||
<div class="h2-milestone-top">
|
||
<div class="flex items-center gap-2.5">
|
||
<div class="h2-milestone-badge" :style="`--ms-color: ${milestoneStatus.color}`">
|
||
<UIcon :name="milestoneStatus.icon" style="width: 15px; height: 15px;" />
|
||
</div>
|
||
<div>
|
||
<span class="h2-milestone-status" :style="`color: ${milestoneStatus.color}`">{{ milestoneStatus.label }}</span>
|
||
<span class="h2-milestone-tag">MTD plan</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Overall progress bar -->
|
||
<div class="h2-milestone-bar-wrap">
|
||
<div class="h2-milestone-bar-track">
|
||
<div class="h2-milestone-bar-fill" :style="`width: ${Math.min(milestoneAvgPct, 120) / 1.2}%; background: ${milestoneStatus.color}`" />
|
||
</div>
|
||
<span class="h2-milestone-bar-pct" :style="`color: ${milestoneStatus.color}`">{{ milestoneAvgPct }}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Metric pills + config -->
|
||
<div class="h2-milestone-bottom">
|
||
<div class="h2-milestone-metrics">
|
||
<div v-for="metric in activeMilestones" :key="metric.id" class="h2-milestone-pill">
|
||
<span class="h2-milestone-pill-label">{{ metric.label }}</span>
|
||
<span class="h2-milestone-pill-val">{{ metric.actual }}</span>
|
||
<span class="h2-milestone-pill-sep">/</span>
|
||
<span class="h2-milestone-pill-target">{{ metric.target }}</span>
|
||
<span
|
||
class="h2-milestone-pill-pct"
|
||
:class="metric.pct >= 100 ? 'h2-milestone-pill-pct--good' : metric.pct >= 85 ? 'h2-milestone-pill-pct--close' : 'h2-milestone-pill-pct--behind'"
|
||
>{{ metric.pct }}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Config button -->
|
||
<div class="h2-mile-config-wrap relative">
|
||
<button
|
||
type="button"
|
||
class="h2-mile-config-btn"
|
||
title="Configure metrics"
|
||
@mousedown.prevent="onMilestoneConfigClick"
|
||
>
|
||
<UIcon name="i-heroicons-cog-6-tooth" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
<div v-show="milestoneConfigOpen" class="h2-mile-popover">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[#8a8a86] px-3 pt-3 pb-1">Track (1–4 metrics)</p>
|
||
<ul class="pb-2">
|
||
<li
|
||
v-for="m in MILESTONE_METRICS"
|
||
:key="m.id"
|
||
class="h2-mile-item"
|
||
:class="activeMilestoneIds.includes(m.id) ? 'h2-mile-item-on' : ''"
|
||
@click="toggleMilestoneMetric(m.id)"
|
||
>
|
||
<div class="h2-mile-check" :class="activeMilestoneIds.includes(m.id) ? 'h2-mile-check-on' : ''">
|
||
<UIcon v-if="activeMilestoneIds.includes(m.id)" name="i-heroicons-check" style="width: 10px; height: 10px;" />
|
||
</div>
|
||
<span class="text-[12px] font-medium text-[var(--text-primary)]">{{ m.label }}</span>
|
||
<span class="ml-auto text-[11px] tabular-nums text-[#8a8a86]">{{ m.actual }} / {{ m.target }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ==================== PERFORMANCE KPIs ==================== -->
|
||
<template #performance>
|
||
<section class="space-y-4" aria-labelledby="perf-h2">
|
||
<div class="h2-section-header">
|
||
<h2 id="perf-h2" class="h2-section-title">Today at a glance</h2>
|
||
<p class="h2-section-sub">Headline operational metrics</p>
|
||
</div>
|
||
|
||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
<div
|
||
v-for="k in welcome.performanceKpis"
|
||
:key="k.id"
|
||
class="h2-card group overflow-hidden p-5 transition-all duration-150 hover:shadow-md"
|
||
>
|
||
<p class="text-[11px] font-semibold uppercase tracking-[0.06em] text-[#8a8a86]">{{ k.label }}</p>
|
||
<div class="mt-2 flex items-end gap-2.5">
|
||
<p class="text-[22px] font-semibold tabular-nums tracking-tight text-[#1a1a18]" style="font-variant-numeric: tabular-nums;">
|
||
{{ k.value }}
|
||
</p>
|
||
<p v-if="k.change" class="mb-0.5 text-xs font-semibold" :class="changeToneClass(k.changeTone)">
|
||
{{ k.change }}
|
||
</p>
|
||
</div>
|
||
<p v-if="k.hint" class="mt-1 text-[11px] leading-snug text-[var(--h2-muted)]">{{ k.hint }}</p>
|
||
|
||
<div class="mt-3 h-7 w-full">
|
||
<svg class="h-full w-full" viewBox="0 0 112 32" fill="none" aria-hidden="true">
|
||
<defs>
|
||
<linearGradient :id="`sg2-${k.id}`" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="var(--h2-accent)" stop-opacity="0.16" />
|
||
<stop offset="100%" stop-color="var(--h2-accent)" stop-opacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
<path v-if="kpiSparkSeries[k.id]" :d="smoothSparklineArea(kpiSparkSeries[k.id]!)" :fill="`url(#sg2-${k.id})`" />
|
||
<path
|
||
v-if="kpiSparkSeries[k.id]" :d="smoothSparklinePath(kpiSparkSeries[k.id]!)"
|
||
fill="none" stroke="var(--h2-accent)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
|
||
class="opacity-60 transition-opacity group-hover:opacity-100"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== TASKS & ALERTS ==================== -->
|
||
<template #tasks_alerts>
|
||
<div class="grid gap-4 lg:grid-cols-2 lg:items-stretch">
|
||
<!-- Tasks -->
|
||
<div class="h2-card overflow-hidden flex flex-col">
|
||
<div class="h2-card-header">
|
||
<div class="h2-icon-box"><UIcon name="i-heroicons-clipboard-document-check" class="h-4 w-4" /></div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-[14px] font-semibold text-[#1a1a18]">Today's tasks</p>
|
||
<p class="text-[13px] text-[#8a8a86]">{{ completedTaskCount }}/{{ totalTaskCount }} done</p>
|
||
</div>
|
||
<button type="button" class="h2-task-add-btn" title="Add task" @click="addingTask = !addingTask">
|
||
<UIcon name="i-heroicons-plus" style="width: 14px; height: 14px;" />
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Progress bar -->
|
||
<div class="h2-task-progress">
|
||
<div
|
||
class="h2-task-progress-fill"
|
||
:style="`width: ${totalTaskCount > 0 ? (completedTaskCount / totalTaskCount * 100) : 0}%`"
|
||
/>
|
||
</div>
|
||
|
||
<!-- Quick add -->
|
||
<Transition
|
||
enter-active-class="transition-all duration-200 ease-out"
|
||
enter-from-class="opacity-0 max-h-0"
|
||
enter-to-class="opacity-100 max-h-14"
|
||
leave-active-class="transition-all duration-150 ease-in"
|
||
leave-from-class="opacity-100 max-h-14"
|
||
leave-to-class="opacity-0 max-h-0"
|
||
>
|
||
<div v-if="addingTask" class="h2-task-add-row">
|
||
<input
|
||
v-model="newTaskText"
|
||
type="text"
|
||
class="h2-task-add-input"
|
||
placeholder="What needs doing?"
|
||
@keydown.enter="addQuickTask"
|
||
@keydown.escape="addingTask = false"
|
||
/>
|
||
<button type="button" class="h2-task-add-submit" :disabled="!newTaskText.trim()" @click="addQuickTask">Add</button>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- Task list -->
|
||
<ul class="h2-list flex-1">
|
||
<TransitionGroup
|
||
enter-active-class="transition-all duration-200 ease-out"
|
||
enter-from-class="opacity-0 -translate-x-2"
|
||
enter-to-class="opacity-100 translate-x-0"
|
||
leave-active-class="transition-all duration-200 ease-in"
|
||
leave-from-class="opacity-100 max-h-12"
|
||
leave-to-class="opacity-0 max-h-0"
|
||
>
|
||
<li
|
||
v-for="task in dashTasks"
|
||
:key="task.id"
|
||
class="h2-task-item group/task"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="h2-task-checkbox"
|
||
:class="task.done ? 'h2-task-checkbox-done' : ''"
|
||
@click="toggleTask(task.id)"
|
||
>
|
||
<UIcon v-if="task.done" name="i-heroicons-check" style="width: 10px; height: 10px;" />
|
||
</button>
|
||
<span
|
||
class="h2-task-text flex-1"
|
||
:class="{
|
||
'h2-task-done': task.done,
|
||
'font-medium text-[var(--h2-fg)]': task.emphasis && !task.done,
|
||
'text-[var(--h2-fg-secondary)]': !task.emphasis && !task.done,
|
||
}"
|
||
>{{ task.title }}</span>
|
||
<button
|
||
v-if="task.id.startsWith('user-task-')"
|
||
type="button"
|
||
class="h2-task-remove"
|
||
title="Remove"
|
||
@click="removeTask(task.id)"
|
||
>
|
||
<UIcon name="i-heroicons-x-mark" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
</li>
|
||
</TransitionGroup>
|
||
</ul>
|
||
</div>
|
||
|
||
<!-- Alerts -->
|
||
<div class="h2-card overflow-hidden flex flex-col">
|
||
<div class="h2-card-header">
|
||
<div class="h2-icon-box h2-icon-box-error"><UIcon name="i-heroicons-bell-alert" class="h-4 w-4" /></div>
|
||
<div class="flex-1 min-w-0">
|
||
<p class="text-[14px] font-semibold text-[#1a1a18]">Alerts</p>
|
||
<p class="text-[13px] text-[#8a8a86]">{{ visibleAlerts.length }} exceptions</p>
|
||
</div>
|
||
<button
|
||
v-if="dashAlertsDismissed.size > 0"
|
||
type="button"
|
||
class="h2-alert-restore"
|
||
@click="dashAlertsDismissed.clear()"
|
||
>
|
||
Restore all
|
||
</button>
|
||
</div>
|
||
<div v-if="visibleAlerts.length === 0" class="px-6 py-8 text-center flex-1 flex flex-col items-center justify-center">
|
||
<UIcon name="i-heroicons-check-circle" style="width: 28px; height: 28px; color: #0f7b5f; margin: 0 auto 6px;" />
|
||
<p class="text-[13px] text-[var(--h2-muted)]">All clear — no pending alerts</p>
|
||
</div>
|
||
<div v-else class="space-y-px px-3 pb-3 flex-1">
|
||
<TransitionGroup
|
||
enter-active-class="transition-all duration-200 ease-out"
|
||
enter-from-class="opacity-0 translate-x-2"
|
||
enter-to-class="opacity-100 translate-x-0"
|
||
leave-active-class="transition-all duration-200 ease-in"
|
||
leave-from-class="opacity-100 max-h-24"
|
||
leave-to-class="opacity-0 max-h-0"
|
||
>
|
||
<div
|
||
v-for="alert in visibleAlerts"
|
||
:key="alert.id"
|
||
class="h2-alert-row group/alert"
|
||
:class="alert.meta.bg"
|
||
>
|
||
<!-- Status rail -->
|
||
<div class="absolute inset-y-1.5 left-0 rounded-full" :style="alert.meta.railStyle + ';width:3px'" />
|
||
<UIcon :name="alert.meta.icon" class="mt-0.5 h-4 w-4 shrink-0 pl-2" :class="alert.meta.iconColor" />
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-[10px] font-bold uppercase tracking-wider" :class="alert.meta.iconColor">{{ alert.meta.label }}</p>
|
||
<p class="mt-0.5 text-[13px] leading-snug text-[var(--h2-fg)]">{{ alert.message }}</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
class="h2-alert-dismiss"
|
||
title="Dismiss"
|
||
@click="dismissAlert(alert.id)"
|
||
>
|
||
<UIcon name="i-heroicons-x-mark" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ==================== CHARTS ==================== -->
|
||
<template #charts>
|
||
<section class="grid gap-4 lg:grid-cols-5" aria-labelledby="ch-h2">
|
||
<!-- Main chart -->
|
||
<div class="lg:col-span-3 h2-card overflow-hidden">
|
||
<!-- Chart toolbar -->
|
||
<div class="h2-chart-toolbar">
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<!-- Metric selector -->
|
||
<div class="relative">
|
||
<button type="button" class="h2-chart-metric-btn" @click.stop="chartMetricMenuOpen = !chartMetricMenuOpen">
|
||
<span class="font-semibold text-[13px] text-[var(--h2-fg)]">{{ selectedChartMetric.label }}</span>
|
||
<UIcon name="i-heroicons-chevron-down" style="width: 12px; height: 12px; color: #8a8a86;" />
|
||
</button>
|
||
<Transition
|
||
enter-active-class="transition duration-150 ease-out"
|
||
enter-from-class="opacity-0 scale-95 -translate-y-1"
|
||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||
leave-active-class="transition duration-100 ease-in"
|
||
leave-from-class="opacity-100"
|
||
leave-to-class="opacity-0 scale-95"
|
||
>
|
||
<div v-if="chartMetricMenuOpen" class="h2-chart-metric-menu">
|
||
<button
|
||
v-for="cm in CHART_METRICS"
|
||
:key="cm.id"
|
||
type="button"
|
||
class="h2-chart-metric-item"
|
||
:class="activeChartMetric === cm.id ? 'h2-chart-metric-item-on' : ''"
|
||
@click="activeChartMetric = cm.id; chartMetricMenuOpen = false"
|
||
>
|
||
<span class="text-[12px]">{{ cm.label }}</span>
|
||
<span class="text-[11px] tabular-nums text-[#8a8a86]">{{ cm.data6m[cm.data6m.length - 1]?.display }}</span>
|
||
</button>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
<!-- Change badge -->
|
||
<span :class="selectedChartMetric.changeTone === 'positive' ? 'h2-badge-success' : 'h2-badge-warning'">{{ selectedChartMetric.change }}</span>
|
||
<span class="font-mono text-[14px] font-bold tabular-nums text-[var(--h2-fg)] hidden sm:inline">{{ chartLatest.display }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<!-- Chart type toggle -->
|
||
<div class="h2-chart-range">
|
||
<button type="button" class="h2-chart-type-btn" :class="activeChartType === 'area' ? 'h2-chart-range-on' : ''" title="Area" @click="activeChartType = 'area'">
|
||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><path d="M1 8L4 4L7 6L13 1V9H1V8Z" fill="currentColor" opacity="0.3"/><path d="M1 8L4 4L7 6L13 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</button>
|
||
<button type="button" class="h2-chart-type-btn" :class="activeChartType === 'line' ? 'h2-chart-range-on' : ''" title="Line" @click="activeChartType = 'line'">
|
||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><path d="M1 8L4 4L7 6L13 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||
</button>
|
||
<button type="button" class="h2-chart-type-btn" :class="activeChartType === 'bar' ? 'h2-chart-range-on' : ''" title="Bar" @click="activeChartType = 'bar'">
|
||
<svg width="14" height="10" viewBox="0 0 14 10" fill="none"><rect x="1" y="5" width="2.5" height="5" rx="0.5" fill="currentColor"/><rect x="5" y="2" width="2.5" height="8" rx="0.5" fill="currentColor"/><rect x="9" y="0" width="2.5" height="10" rx="0.5" fill="currentColor"/></svg>
|
||
</button>
|
||
</div>
|
||
<div class="h2-chart-sep" />
|
||
<!-- Time range -->
|
||
<div class="h2-chart-range">
|
||
<button type="button" class="h2-chart-range-btn" :class="chartTimeRange === '3m' ? 'h2-chart-range-on' : ''" @click="chartTimeRange = '3m'">3M</button>
|
||
<button type="button" class="h2-chart-range-btn" :class="chartTimeRange === '6m' ? 'h2-chart-range-on' : ''" @click="chartTimeRange = '6m'">6M</button>
|
||
<button type="button" class="h2-chart-range-btn" :class="chartTimeRange === '12m' ? 'h2-chart-range-on' : ''" @click="chartTimeRange = '12m'">12M</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Chart canvas -->
|
||
<div class="px-2 pb-2 pt-1">
|
||
<div class="overflow-hidden rounded-lg bg-[var(--h2-surface-inset)] ring-1 ring-[var(--h2-border-strong)]">
|
||
<svg class="h-auto w-full" :viewBox="`0 0 ${chartSvgModel.viewW} ${chartSvgModel.viewH}`" role="img">
|
||
<title>{{ selectedChartMetric.label }} trend</title>
|
||
<defs>
|
||
<linearGradient id="chartGrad" x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stop-color="var(--h2-accent)" stop-opacity="0.2" />
|
||
<stop offset="60%" stop-color="var(--h2-accent)" stop-opacity="0.04" />
|
||
<stop offset="100%" stop-color="var(--h2-accent)" stop-opacity="0" />
|
||
</linearGradient>
|
||
</defs>
|
||
<!-- Grid -->
|
||
<line v-for="(gy, i) in chartSvgModel.gridYs" :key="'g'+i" class="stroke-[var(--h2-border)]" stroke-width="1"
|
||
:x1="chartSvgModel.padX" :y1="gy" :x2="chartSvgModel.padX + chartSvgModel.innerW" :y2="gy" />
|
||
|
||
<!-- Area chart -->
|
||
<template v-if="activeChartType === 'area'">
|
||
<path :d="chartSvgModel.areaD" fill="url(#chartGrad)" class="transition-all duration-500" />
|
||
<path :d="chartSvgModel.lineD" fill="none" stroke="var(--h2-accent)" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" class="transition-all duration-500" />
|
||
<g v-for="(pt, i) in chartSvgModel.points" :key="'p'+i">
|
||
<circle :cx="pt.x" :cy="pt.y" r="4.5" fill="var(--h2-surface)" stroke="var(--h2-accent)" stroke-width="2" class="transition-all duration-500" />
|
||
</g>
|
||
</template>
|
||
|
||
<!-- Line chart -->
|
||
<template v-if="activeChartType === 'line'">
|
||
<path :d="chartSvgModel.lineD" fill="none" stroke="var(--h2-accent)" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" class="transition-all duration-500" />
|
||
<g v-for="(pt, i) in chartSvgModel.points" :key="'lp'+i">
|
||
<circle :cx="pt.x" :cy="pt.y" r="3.5" fill="var(--h2-accent)" class="transition-all duration-500" />
|
||
</g>
|
||
</template>
|
||
|
||
<!-- Bar chart -->
|
||
<template v-if="activeChartType === 'bar'">
|
||
<rect
|
||
v-for="(bar, i) in chartSvgModel.bars"
|
||
:key="'b'+i"
|
||
:x="bar.x" :y="bar.y" :width="bar.w" :height="bar.h"
|
||
rx="3"
|
||
fill="var(--h2-accent)"
|
||
:opacity="0.5 + (i / chartSvgModel.bars.length) * 0.5"
|
||
class="transition-all duration-500"
|
||
/>
|
||
</template>
|
||
</svg>
|
||
<!-- X-axis labels -->
|
||
<div class="flex justify-between border-t border-[var(--h2-border)] px-3 pb-2 pt-1.5">
|
||
<div v-for="row in chartData" :key="row.m" class="flex-1 text-center">
|
||
<p class="font-mono text-[10px] font-semibold tabular-nums text-[var(--h2-fg)]">{{ row.display }}</p>
|
||
<p class="text-[10px] text-[var(--h2-muted)]">{{ row.m }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pipeline -->
|
||
<div class="lg:col-span-2 h2-card flex flex-col overflow-hidden">
|
||
<div class="flex items-center justify-between px-5 pt-5">
|
||
<div>
|
||
<h2 class="text-[14px] font-semibold text-[#1a1a18]">Pipeline</h2>
|
||
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Book, open quotes & YTD</p>
|
||
</div>
|
||
<!-- Pipeline view toggle -->
|
||
<div class="h2-chart-range">
|
||
<button type="button" class="h2-chart-range-btn" :class="pipelineView === 'summary' ? 'h2-chart-range-on' : ''" @click="pipelineView = 'summary'">Mix</button>
|
||
<button type="button" class="h2-chart-range-btn" :class="pipelineView === 'stage' ? 'h2-chart-range-on' : ''" @click="pipelineView = 'stage'">Stage</button>
|
||
<button type="button" class="h2-chart-range-btn" :class="pipelineView === 'lob' ? 'h2-chart-range-on' : ''" @click="pipelineView = 'lob'">LOB</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Summary trio (always shown) -->
|
||
<div class="mx-4 mt-4 grid grid-cols-3 overflow-hidden rounded-lg ring-1 ring-[var(--h2-border-strong)]">
|
||
<div v-for="item in quotedPipelineSummaryCards" :key="item.label" class="bg-[var(--h2-surface-inset)] px-3 py-3 text-center ring-1 ring-[var(--h2-border)]">
|
||
<p class="font-mono text-base font-bold tabular-nums text-[var(--h2-fg)]">{{ item.value }}</p>
|
||
<p class="mt-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--h2-muted)]">{{ item.label }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pipeline sub-views -->
|
||
<div class="mt-4 flex-1 px-5 pb-5">
|
||
<!-- Mix view (segment breakdown) -->
|
||
<template v-if="pipelineView === 'summary'">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Segment mix</p>
|
||
<div class="mt-2.5 flex h-2.5 w-full overflow-hidden rounded-md" style="outline:1px solid var(--h2-border)">
|
||
<div
|
||
v-for="(row, i) in pipelineMixRows"
|
||
:key="row.label"
|
||
class="h-full cursor-pointer transition-all duration-500"
|
||
:style="segColorStyles[i] + ';width:' + row.pct + '%;opacity:' + (hoveredSegment === null || hoveredSegment === i ? '1' : '0.3')"
|
||
:title="`${row.label}: ${row.pct}%`"
|
||
@mouseenter="hoveredSegment = i"
|
||
@mouseleave="hoveredSegment = null"
|
||
/>
|
||
</div>
|
||
<div class="mt-3.5 space-y-2">
|
||
<div
|
||
v-for="(row, i) in pipelineMixRows"
|
||
:key="row.label"
|
||
class="flex items-center gap-2 rounded-md px-1 py-0.5 -mx-1 cursor-pointer transition-all duration-150"
|
||
:style="hoveredSegment === i ? 'background: rgba(0,0,0,0.03)' : ''"
|
||
:class="hoveredSegment !== null && hoveredSegment !== i ? 'opacity-40' : ''"
|
||
@mouseenter="hoveredSegment = i"
|
||
@mouseleave="hoveredSegment = null"
|
||
>
|
||
<div class="h-2 w-2 shrink-0 rounded-sm" :style="segDotStyles[i]" />
|
||
<span class="flex-1 truncate text-[12px] font-medium text-[var(--h2-fg)]">{{ row.label }}</span>
|
||
<span class="font-mono text-[12px] tabular-nums text-[var(--h2-muted)]">{{ formatPremiumM(row.premiumM) }}</span>
|
||
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--h2-muted)]">{{ row.pct }}%</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Stage funnel view -->
|
||
<template v-if="pipelineView === 'stage'">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Pipeline stages</p>
|
||
<div class="mt-3 space-y-2.5">
|
||
<div v-for="stage in pipelineStages" :key="stage.label" class="h2-pipeline-stage">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<span class="text-[12px] font-medium text-[var(--h2-fg)]">{{ stage.label }}</span>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-[11px] tabular-nums text-[var(--h2-muted)]">{{ stage.count }}</span>
|
||
<span class="font-mono text-[11px] font-semibold tabular-nums text-[var(--h2-fg)]">{{ stage.value }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="h2-pipeline-bar">
|
||
<div class="h2-pipeline-bar-fill" :style="`width: ${stage.pct}%; background: ${stage.color}`" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- LOB breakdown view -->
|
||
<template v-if="pipelineView === 'lob'">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">By line of business</p>
|
||
<div class="mt-3 space-y-2.5">
|
||
<div v-for="lob in pipelineLobBreakdown" :key="lob.label" class="h2-pipeline-stage">
|
||
<div class="flex items-center justify-between mb-1">
|
||
<span class="text-[12px] font-medium text-[var(--h2-fg)]">{{ lob.label }}</span>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-[11px] tabular-nums text-[var(--h2-muted)]">{{ lob.count }} quotes</span>
|
||
<span class="font-mono text-[11px] font-semibold tabular-nums text-[var(--h2-fg)]">{{ lob.value }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="h2-pipeline-bar">
|
||
<div class="h2-pipeline-bar-fill" :style="`width: ${lob.pct}%; background: var(--h2-accent)`" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== BROKERAGE HEALTH ==================== -->
|
||
<template #brokerage_health>
|
||
<section v-if="welcome.ceoKpis?.length" class="space-y-4">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Brokerage health</h2>
|
||
<p class="h2-section-sub">YTD and trailing measures</p>
|
||
</div>
|
||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||
<div v-for="k in welcome.ceoKpis" :key="k.id" class="h2-card p-5 transition-all duration-150 hover:shadow-md">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ k.label }}</p>
|
||
<p class="mt-1.5 font-mono text-xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">{{ k.value }}</p>
|
||
<p v-if="k.change" class="mt-1 text-xs font-semibold" :class="changeToneClass(k.changeTone)">{{ k.change }}</p>
|
||
<p v-if="k.hint" class="mt-1 text-[11px] text-[var(--h2-muted)]">{{ k.hint }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== SENT QUOTES ==================== -->
|
||
<template #quotes_line>
|
||
<section class="space-y-4" aria-labelledby="h2q">
|
||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||
<div class="h2-section-header">
|
||
<h2 id="h2q" class="h2-section-title">Sent Quotes</h2>
|
||
<p class="h2-section-sub">{{ filteredSentQuotes.length }} quotes · ${{ (filteredSentQuotes.reduce((s, q) => s + q.premium, 0) / 1000).toFixed(0) }}K total premium</p>
|
||
</div>
|
||
<NuxtLink to="/quotes" class="text-[13px] font-medium h2-text-accent transition hover:h2-text-accent-hover">
|
||
All quotes →
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<!-- Filters row -->
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<select v-model="quoteSortKey" class="h2-qs-select">
|
||
<option v-for="o in quoteSortOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||
</select>
|
||
<select v-model="quoteFilterLob" class="h2-qs-select">
|
||
<option v-for="o in quoteLobOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||
</select>
|
||
<select v-model="quoteFilterParty" class="h2-qs-select">
|
||
<option v-for="o in quotePartyOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||
</select>
|
||
<select v-model="quoteFilterStatus" class="h2-qs-select">
|
||
<option v-for="o in quoteStatusOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Quotes list -->
|
||
<div class="h2-card overflow-hidden">
|
||
<div v-if="filteredSentQuotes.length === 0" class="px-6 py-10 text-center">
|
||
<UIcon name="i-heroicons-document-text" style="width: 32px; height: 32px; color: #c0c0bc; margin: 0 auto 8px;" />
|
||
<p class="text-[13px] text-[var(--h2-muted)]">No quotes match these filters</p>
|
||
</div>
|
||
<div
|
||
v-for="(qt, qi) in filteredSentQuotes"
|
||
:key="qt.id"
|
||
class="h2-qs-row"
|
||
:class="qi < filteredSentQuotes.length - 1 ? 'h2-qs-row-border' : ''"
|
||
>
|
||
<!-- LOB icon -->
|
||
<div class="h2-qs-lob-icon">
|
||
<UIcon :name="qt.lobIcon" style="width: 16px; height: 16px;" />
|
||
</div>
|
||
|
||
<!-- Main info -->
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2 flex-wrap">
|
||
<p class="text-[13px] font-medium text-[var(--h2-fg)]">{{ qt.customer }}</p>
|
||
<span class="h2-qs-party">{{ qt.party === 'corporate' ? 'Corp' : 'Personal' }}</span>
|
||
<span :class="quoteStatusMeta[qt.status].cls">{{ quoteStatusMeta[qt.status].label }}</span>
|
||
</div>
|
||
<div class="mt-0.5 flex items-center gap-2 flex-wrap text-[11px] text-[var(--h2-muted)]">
|
||
<span class="font-mono">{{ qt.id }}</span>
|
||
<span class="h2-qs-sep" />
|
||
<span>{{ qt.lob }}</span>
|
||
<span class="h2-qs-sep" />
|
||
<span>{{ qt.carrier }}</span>
|
||
<span class="h2-qs-sep" />
|
||
<span>{{ qt.agent }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Numbers -->
|
||
<div class="text-right shrink-0">
|
||
<p class="text-[14px] font-semibold tabular-nums text-[var(--h2-fg)]">${{ qt.premium.toLocaleString() }}</p>
|
||
<p class="text-[11px] tabular-nums text-[var(--h2-muted)]">${{ qt.commission.toLocaleString() }} comm</p>
|
||
</div>
|
||
|
||
<!-- Date -->
|
||
<div class="hidden sm:block shrink-0 text-right">
|
||
<p class="text-[12px] tabular-nums text-[var(--h2-muted)]">{{ qt.sentDate }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== CALENDAR SUMMARY ==================== -->
|
||
<template #calendar>
|
||
<section class="space-y-4">
|
||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Today's agenda</h2>
|
||
<p class="h2-section-sub">{{ filteredCalEvents.filter(e => e.urgent).length }} urgent · {{ filteredCalEvents.length }} total</p>
|
||
</div>
|
||
<NuxtLink to="/calendar" class="text-[13px] font-medium h2-text-accent transition hover:h2-text-accent-hover">
|
||
Open calendar →
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div class="h2-card overflow-hidden">
|
||
<!-- Compact upcoming list (max 4) -->
|
||
<div class="cal-event-list">
|
||
<div
|
||
v-for="event in filteredCalEvents.slice(0, 4)"
|
||
:key="event.id"
|
||
class="cal-event-row"
|
||
:class="event.urgent ? 'cal-event-urgent' : ''"
|
||
>
|
||
<div class="cal-event-time">{{ event.time }}</div>
|
||
<div class="cal-event-dot" :style="calEventDotStyle(event.type)" />
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<p class="text-[13px] font-medium text-[var(--h2-fg)]">{{ event.title }}</p>
|
||
<span v-if="event.urgent" class="cal-urgent-badge">Urgent</span>
|
||
</div>
|
||
<p v-if="event.customer" class="mt-0.5 text-[12px] text-[var(--h2-muted)]">{{ event.customer }}</p>
|
||
</div>
|
||
<UIcon :name="calEventTypeMeta[event.type].icon" class="cal-event-type-icon" :style="`color: ${calEventTypeMeta[event.type].color}`" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- "More" footer -->
|
||
<div v-if="filteredCalEvents.length > 4" class="cal-summary-footer">
|
||
<NuxtLink to="/calendar" class="text-[12px] font-medium h2-text-accent">
|
||
+{{ filteredCalEvents.length - 4 }} more today
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== NOTES ==================== -->
|
||
<template #notes>
|
||
<section class="space-y-3">
|
||
<div class="flex items-end justify-between gap-3">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Notes</h2>
|
||
<p class="h2-section-sub">{{ stickyNotes.length }} notes · auto-saved</p>
|
||
</div>
|
||
<button type="button" class="h2-sticky-add" @click="addStickyNote">
|
||
<UIcon name="i-heroicons-plus" style="width: 12px; height: 12px;" />
|
||
New note
|
||
</button>
|
||
</div>
|
||
|
||
<div class="h2-notes-stack">
|
||
<TransitionGroup
|
||
enter-active-class="transition-all duration-200 ease-out"
|
||
enter-from-class="opacity-0 -translate-y-1 scale-95"
|
||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||
leave-active-class="transition-all duration-150 ease-in"
|
||
leave-from-class="opacity-100 max-h-60"
|
||
leave-to-class="opacity-0 max-h-0"
|
||
>
|
||
<div
|
||
v-for="note in sortedStickyNotes"
|
||
:key="note.id"
|
||
class="h2-note-card group/note"
|
||
:class="note.expanded ? 'h2-note-expanded' : ''"
|
||
>
|
||
<!-- Color accent bar -->
|
||
<div class="h2-note-accent" :style="`background: ${stickyColor(note.color).border}`" />
|
||
|
||
<!-- Collapsed: single-line preview -->
|
||
<div v-if="!note.expanded" class="h2-note-collapsed" @click="toggleStickyExpand(note.id)">
|
||
<UIcon v-if="note.pinned" name="i-heroicons-bookmark-solid" style="width: 11px; height: 11px; color: var(--h2-accent); flex-shrink: 0;" />
|
||
<span class="h2-note-preview">{{ stickyPreview(note.content) }}</span>
|
||
<div class="h2-note-actions">
|
||
<button type="button" class="h2-note-action" title="Pin" @click.stop="toggleStickyPin(note.id)">
|
||
<UIcon :name="note.pinned ? 'i-heroicons-bookmark-solid' : 'i-heroicons-bookmark'" style="width: 11px; height: 11px;" />
|
||
</button>
|
||
<button type="button" class="h2-note-action h2-note-action-color" title="Color" @click.stop="cycleStickyColor(note.id)">
|
||
<span class="h2-note-colordot" :style="`background: ${stickyColor(note.color).border}`" />
|
||
</button>
|
||
<button type="button" class="h2-note-action h2-note-action-danger" title="Delete" @click.stop="removeStickyNote(note.id)">
|
||
<UIcon name="i-heroicons-trash" style="width: 11px; height: 11px;" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Expanded: editable textarea -->
|
||
<div v-else class="h2-note-body">
|
||
<div class="h2-note-body-head">
|
||
<div class="flex items-center gap-1.5">
|
||
<button type="button" class="h2-note-action" title="Pin" @click="toggleStickyPin(note.id)">
|
||
<UIcon :name="note.pinned ? 'i-heroicons-bookmark-solid' : 'i-heroicons-bookmark'" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
<div class="flex gap-0.5">
|
||
<button
|
||
v-for="c in STICKY_COLORS"
|
||
:key="c.id"
|
||
type="button"
|
||
class="h2-note-colordot-pick"
|
||
:class="note.color === c.id ? 'h2-note-colordot-active' : ''"
|
||
:style="`background: ${c.border}`"
|
||
:title="c.label"
|
||
@click="note.color = c.id"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1">
|
||
<button type="button" class="h2-note-action h2-note-action-danger" title="Delete" @click="removeStickyNote(note.id)">
|
||
<UIcon name="i-heroicons-trash" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
<button type="button" class="h2-note-action" title="Collapse" @click="toggleStickyExpand(note.id)">
|
||
<UIcon name="i-heroicons-chevron-up" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<textarea
|
||
v-model="note.content"
|
||
class="h2-note-textarea"
|
||
placeholder="Write something..."
|
||
rows="4"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</TransitionGroup>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== QUICK LEADS: Last 10 days ==================== -->
|
||
<template #quick_leads>
|
||
<section class="space-y-3">
|
||
<div class="flex items-end justify-between gap-3">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Quick Leads</h2>
|
||
<p class="h2-section-sub">Last 10 days · {{ dashQuickLeads.length }} leads</p>
|
||
</div>
|
||
<NuxtLink to="/sales/quick-lead" class="h2-ql-view-all">
|
||
View all
|
||
<UIcon name="i-heroicons-arrow-right" style="width: 10px; height: 10px;" />
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div v-if="dashQuickLeads.length === 0" key="ql-empty" class="h2-card h2-ql-empty">
|
||
<UIcon name="i-heroicons-bolt" style="width: 24px; height: 24px; color: #c0c0bc;" />
|
||
<p class="text-[12px] text-[var(--h2-muted)] mt-1.5">No quick leads in the last 10 days.</p>
|
||
<NuxtLink to="/sales/quick-lead" class="h2-ql-add-link">
|
||
<UIcon name="i-heroicons-plus" style="width: 10px; height: 10px;" />
|
||
Capture a lead
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div v-else key="ql-list" class="h2-card h2-card-flush overflow-hidden">
|
||
<div v-for="(lead, i) in dashQuickLeads" :key="lead.id" class="h2-ql-row" :class="i < dashQuickLeads.length - 1 ? 'h2-ql-row-border' : ''">
|
||
<div class="h2-ql-avatar">{{ lead.name.split(' ').map(w => w[0]).join('').slice(0, 2) }}</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<p class="text-[12px] font-medium text-[var(--h2-fg)] truncate">{{ lead.name }}</p>
|
||
<span :class="qlPriorityMeta(lead.priority).cls">{{ qlPriorityMeta(lead.priority).label }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 mt-0.5 flex-wrap">
|
||
<span class="text-[11px] text-[var(--h2-muted)]">{{ lead.product }}</span>
|
||
<span class="h2-ql-sep" />
|
||
<span class="text-[11px] text-[var(--h2-muted)]">{{ qlTimeAgo(lead.createdAt) }}</span>
|
||
<template v-if="lead.source">
|
||
<span class="h2-ql-sep" />
|
||
<span class="h2-ql-source">
|
||
<UIcon name="i-heroicons-map-pin" style="width: 10px; height: 10px;" />
|
||
{{ lead.source }}
|
||
</span>
|
||
</template>
|
||
</div>
|
||
<p v-if="lead.note" class="mt-0.5 text-[11px] text-[var(--h2-muted)] truncate opacity-70">{{ lead.note }}</p>
|
||
</div>
|
||
<div class="hidden sm:flex flex-col items-end gap-0.5 shrink-0 text-right">
|
||
<span v-if="lead.phone" class="text-[11px] text-[var(--h2-muted)] tabular-nums">{{ lead.phone }}</span>
|
||
<span v-if="lead.email" class="text-[11px] text-[var(--h2-muted)] truncate max-w-[160px]">{{ lead.email }}</span>
|
||
</div>
|
||
<NuxtLink to="/quotes/new" class="h2-ql-quote-btn" title="Start quote">
|
||
<UIcon name="i-heroicons-calculator" style="width: 12px; height: 12px;" />
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== SALES LEADS ==================== -->
|
||
<template #sales_leads>
|
||
<section class="space-y-3">
|
||
<div class="flex items-end justify-between gap-3">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Sales Leads</h2>
|
||
<p class="h2-section-sub">{{ filteredSalesLeads.length }} leads{{ slSourceFilter !== 'all' ? ' · ' + slSourceLabel(slSourceFilter) : '' }}</p>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<span class="text-[10px] font-medium text-[var(--h2-muted)] uppercase tracking-wide hidden sm:inline">API integrations coming soon</span>
|
||
<NuxtLink to="/sales" class="h2-ql-view-all">
|
||
Manage leads
|
||
<UIcon name="i-heroicons-arrow-right" style="width: 10px; height: 10px;" />
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Source filter pills -->
|
||
<div class="flex items-center gap-1.5 overflow-x-auto pb-1 -mb-1">
|
||
<button
|
||
v-for="src in SALES_LEAD_SOURCES"
|
||
:key="src.value"
|
||
type="button"
|
||
class="h2-sl-filter-pill"
|
||
:class="slSourceFilter === src.value ? 'h2-sl-filter-pill--active' : ''"
|
||
@click="slSourceFilter = src.value as any"
|
||
>
|
||
<UIcon :name="src.icon" style="width: 11px; height: 11px;" />
|
||
{{ src.label }}
|
||
<span v-if="slSourceCounts[src.value]" class="h2-sl-filter-count">{{ slSourceCounts[src.value] }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Leads table -->
|
||
<div class="h2-card h2-card-flush overflow-hidden">
|
||
<!-- Header row -->
|
||
<div class="h2-sl-header">
|
||
<span class="flex-1">Lead</span>
|
||
<span class="w-[80px] text-center hidden sm:block">Source</span>
|
||
<span class="w-[70px] text-center hidden sm:block">Status</span>
|
||
<span class="w-[70px] text-right hidden md:block">Value</span>
|
||
<span class="w-[80px] text-right hidden lg:block">Assigned</span>
|
||
<span class="w-[24px]"></span>
|
||
</div>
|
||
|
||
<div v-if="filteredSalesLeads.length === 0" class="h2-sl-empty">
|
||
<UIcon name="i-heroicons-funnel" style="width: 20px; height: 20px; color: #c0c0bc;" />
|
||
<p>No leads match this filter.</p>
|
||
</div>
|
||
|
||
<div v-for="(lead, i) in filteredSalesLeads" :key="lead.id" class="h2-sl-row" :class="i < filteredSalesLeads.length - 1 ? 'h2-ql-row-border' : ''">
|
||
<!-- Avatar + name block -->
|
||
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||
<div class="h2-ql-avatar" :style="'background: rgba(1,105,111,0.07); color: #01696f'">{{ lead.name.split(' ').map((w: string) => w[0]).join('').slice(0, 2) }}</div>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="flex items-center gap-2">
|
||
<p class="text-[12px] font-medium text-[var(--h2-fg)] truncate">{{ lead.name }}</p>
|
||
<span class="text-[10px] text-[var(--h2-muted)]">{{ lead.lob }}</span>
|
||
</div>
|
||
<div class="flex items-center gap-1 mt-0.5">
|
||
<span v-if="lead.phone" class="text-[10px] text-[var(--h2-muted)] tabular-nums">{{ lead.phone }}</span>
|
||
<span v-if="lead.phone && lead.email" class="h2-ql-sep" />
|
||
<span v-if="lead.email" class="text-[10px] text-[var(--h2-muted)] truncate">{{ lead.email }}</span>
|
||
</div>
|
||
<p v-if="lead.note || lead.campaignName" class="text-[10px] text-[var(--h2-muted)] truncate opacity-60 mt-0.5">
|
||
{{ lead.campaignName ? '📣 ' + lead.campaignName : lead.note }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<!-- Source -->
|
||
<div class="w-[80px] hidden sm:flex items-center justify-center">
|
||
<span class="h2-sl-source-chip">
|
||
<UIcon :name="slSourceIcon(lead.source)" style="width: 10px; height: 10px;" />
|
||
{{ slSourceLabel(lead.source) }}
|
||
</span>
|
||
</div>
|
||
<!-- Status -->
|
||
<div class="w-[70px] hidden sm:flex items-center justify-center">
|
||
<span :class="slStatusBadge(lead.status).cls">{{ slStatusBadge(lead.status).label }}</span>
|
||
</div>
|
||
<!-- Value -->
|
||
<div class="w-[70px] hidden md:flex items-center justify-end">
|
||
<span v-if="lead.value" class="text-[11px] font-medium text-[var(--h2-fg)] tabular-nums">{{ slFmtValue(lead.value) }}</span>
|
||
</div>
|
||
<!-- Assigned -->
|
||
<div class="w-[80px] hidden lg:flex items-center justify-end">
|
||
<span class="text-[10px] text-[var(--h2-muted)]">{{ lead.assignedTo }}</span>
|
||
</div>
|
||
<!-- Action -->
|
||
<NuxtLink to="/quotes/new" class="h2-ql-quote-btn" title="Start quote">
|
||
<UIcon name="i-heroicons-arrow-right" style="width: 10px; height: 10px;" />
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== CLIENT FAVORITES ==================== -->
|
||
<template #client_favorites>
|
||
<section class="space-y-3">
|
||
<div class="flex items-end justify-between gap-3">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Favorite Clients</h2>
|
||
<p class="h2-section-sub">{{ favCustomers.length }} starred</p>
|
||
</div>
|
||
<NuxtLink to="/customers" class="h2-ql-view-all">
|
||
All customers
|
||
<UIcon name="i-heroicons-arrow-right" style="width: 10px; height: 10px;" />
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div v-if="favCustomers.length === 0" class="h2-ql-empty">
|
||
<UIcon name="i-heroicons-star" style="width: 24px; height: 24px; color: #c0c0bc;" />
|
||
<p class="text-[12px] text-[var(--h2-muted)] mt-1.5">No favorite clients yet. Star clients from the customer list.</p>
|
||
</div>
|
||
|
||
<div v-else class="h2-fav-grid">
|
||
<NuxtLink
|
||
v-for="c in favCustomers" :key="c.id"
|
||
:to="`/customers/${c.id}`"
|
||
class="h2-fav-card"
|
||
>
|
||
<div class="h2-fav-top">
|
||
<div class="h2-fav-avatar">{{ c.initials }}</div>
|
||
<button
|
||
type="button"
|
||
class="h2-fav-star"
|
||
title="Remove from favorites"
|
||
@click.prevent.stop="unfavClient(c.id)"
|
||
>
|
||
<UIcon name="i-heroicons-star-solid" style="width: 12px; height: 12px;" />
|
||
</button>
|
||
</div>
|
||
<p class="h2-fav-name">{{ c.name }}</p>
|
||
<span :class="favTierClass(c)" class="h2-fav-tier">{{ favTierLabel(c) }}</span>
|
||
<div class="h2-fav-stats">
|
||
<span>{{ c.policies.length }} {{ c.policies.length === 1 ? 'policy' : 'policies' }}</span>
|
||
<span v-if="favTotalPremium(c) > 0">{{ favFmtMoney(favTotalPremium(c)) }}/yr</span>
|
||
</div>
|
||
<div class="h2-fav-meta">
|
||
<span>{{ c.agent }}</span>
|
||
<span>{{ c.type }}</span>
|
||
</div>
|
||
</NuxtLink>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<!-- ==================== DRAFTS ==================== -->
|
||
<template #drafts>
|
||
<section class="space-y-3">
|
||
<div class="h2-section-header">
|
||
<h2 class="h2-section-title">Drafts</h2>
|
||
<p class="h2-section-sub">Resume in-progress work</p>
|
||
</div>
|
||
|
||
<div v-if="activeDrafts.length > 0" class="grid gap-2">
|
||
<div
|
||
v-for="draft in activeDrafts"
|
||
:key="draft.key"
|
||
class="h2-card flex items-center gap-3 px-4 py-3"
|
||
>
|
||
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" style="background: rgba(1,105,111,0.08); color: #01696f;">
|
||
<UIcon :name="draft.icon" style="width: 16px; height: 16px;" />
|
||
</div>
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-[13px] font-semibold text-[var(--h2-fg)]">{{ draft.label }}</p>
|
||
<p class="text-[11px] text-[var(--h2-muted)]">Saved {{ draft.age }}</p>
|
||
</div>
|
||
<NuxtLink :to="draft.route" class="text-[12px] font-semibold" style="color: #01696f;">Resume</NuxtLink>
|
||
<button type="button" class="text-[var(--h2-muted)] opacity-50 hover:opacity-100 transition" title="Discard draft" @click="removeDraft(draft.key)">
|
||
<UIcon name="i-heroicons-x-mark" style="width: 14px; height: 14px;" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="h2-card px-4 py-6 text-center">
|
||
<p class="text-[12px] text-[var(--h2-muted)]">No drafts in progress</p>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</HomeDashboardWidgetBlocks>
|
||
|
||
<!-- Layout controls -->
|
||
<div class="mx-auto mt-12 flex max-w-6xl flex-col gap-3 border-t border-[var(--h2-border)] pt-6 sm:flex-row sm:items-center sm:justify-between">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<UButton
|
||
:icon="layoutUnlocked ? 'i-heroicons-arrows-up-down' : 'i-heroicons-lock-closed'"
|
||
:color="layoutUnlocked ? 'primary' : 'neutral'" variant="soft" class="h2-btn-outline"
|
||
@click="toggleLayoutUnlock"
|
||
>{{ layoutUnlocked ? 'Reorder on' : 'Reorder off' }}</UButton>
|
||
<p class="max-w-md text-xs text-[var(--h2-muted)]">Drag blocks by the grip when reorder is on.</p>
|
||
</div>
|
||
<UButton icon="i-heroicons-squares-2x2" color="primary" class="h2-btn-primary shrink-0" @click="dashConfigOpen = true">
|
||
Layout & widgets
|
||
</UButton>
|
||
</div>
|
||
|
||
<!-- Slideover -->
|
||
<USlideover v-model:open="dashConfigOpen" side="right">
|
||
<template #content>
|
||
<div class="flex h-full max-w-md flex-col bg-[var(--h2-surface)] sm:max-w-lg">
|
||
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-[var(--h2-border)] p-6">
|
||
<div class="min-w-0">
|
||
<h2 class="text-lg font-semibold text-[var(--h2-fg)]">Dashboard layout</h2>
|
||
<p class="mt-1 text-sm text-[var(--h2-fg-secondary)]">Choose a role preset or toggle sections.</p>
|
||
</div>
|
||
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" class="shrink-0" aria-label="Close" @click="dashConfigOpen = false" />
|
||
</div>
|
||
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
||
<div class="space-y-6">
|
||
<div>
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Role preset</p>
|
||
<USelect :model-value="activePreset" :items="presetSelectItems" value-key="value" label-key="label" class="mt-2 w-full"
|
||
@update:model-value="applyPreset($event as DashboardRolePresetId)" />
|
||
<p class="mt-2 text-xs text-[var(--h2-muted)]">{{ activePresetHint }}</p>
|
||
<UButton v-if="isPresetDirty" size="xs" color="neutral" variant="soft" class="mt-2" @click="reapplySelectedPreset">Reset to preset</UButton>
|
||
</div>
|
||
<div class="border-t border-[var(--h2-border)] pt-4">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Sections</p>
|
||
<ul class="mt-3 space-y-4">
|
||
<li v-for="w in DASHBOARD_WIDGETS" :key="w.id" class="flex items-start justify-between gap-3">
|
||
<div class="min-w-0">
|
||
<p class="text-sm font-medium text-[var(--h2-fg)]">{{ w.label }}</p>
|
||
<p class="text-xs text-[var(--h2-muted)]">{{ w.description }}</p>
|
||
</div>
|
||
<USwitch :model-value="widgets[w.id]" @update:model-value="setWidget(w.id, $event)" />
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</USlideover>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* =====================================================================
|
||
HOME 2 — PETROLEUM / STONE DESIGN SYSTEM (scoped)
|
||
===================================================================== */
|
||
|
||
/* ---- Palette ---- */
|
||
.h2 {
|
||
/* Petroleum accent scale */
|
||
/* Map scoped vars to global theme tokens for theme-awareness */
|
||
--h2-accent: var(--brand, #0d5c63);
|
||
--h2-accent-hover: var(--brand-hover, #094a50);
|
||
--h2-accent-muted: color-mix(in srgb, var(--brand) 60%, var(--text-muted));
|
||
--h2-accent-soft: var(--brand) / 0.08;
|
||
|
||
/* Surfaces — inherit from global theme */
|
||
--h2-page-bg: var(--page-bg, #f4f2ef);
|
||
--h2-surface: var(--surface, #faf9f7);
|
||
--h2-surface-raised:var(--surface-elevated, #ffffff);
|
||
--h2-surface-inset: var(--page-bg, #f0eeeb);
|
||
|
||
/* Foregrounds — inherit from global theme */
|
||
--h2-fg: var(--text-primary, #1a1a1a);
|
||
--h2-fg-secondary: var(--text-muted, #5c5650);
|
||
--h2-muted: var(--text-muted, #8c857d);
|
||
|
||
/* Borders — inherit from global theme */
|
||
--h2-border: var(--card-border, #e5e0da);
|
||
--h2-border-strong: var(--sidebar-border, #d5cfc8);
|
||
|
||
/* Semantic — inherit from global theme */
|
||
--h2-success: var(--success, #0f7b5f);
|
||
--h2-warning: var(--warning, #c27b1a);
|
||
--h2-error: var(--error, #c13838);
|
||
--h2-info: var(--brand, #0d5c63);
|
||
|
||
background: var(--h2-page-bg);
|
||
}
|
||
|
||
/* ---- Card system ---- */
|
||
.h2-card {
|
||
background: #ffffff;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||
}
|
||
.h2-card-flush {
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ---- Ops cells (modernised command bar — unified card) ---- */
|
||
.h2-ops-cell {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 12px;
|
||
padding: 16px 20px;
|
||
transition: background 150ms ease;
|
||
}
|
||
.h2-ops-cell:hover {
|
||
background: rgba(0, 0, 0, 0.015);
|
||
}
|
||
/* Lead cell gets a very subtle warm accent wash */
|
||
.h2-ops-cell--lead {
|
||
background: rgba(1, 105, 111, 0.025);
|
||
}
|
||
.h2-ops-cell--lead:hover {
|
||
background: rgba(1, 105, 111, 0.04);
|
||
}
|
||
.h2-ops-cell--lead .h2-ops-cell-value {
|
||
color: #01696f;
|
||
}
|
||
.h2-ops-cell-icon {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 30px; height: 30px;
|
||
border-radius: 8px;
|
||
flex-shrink: 0;
|
||
margin-top: 1px;
|
||
}
|
||
.h2-ops-cell-body {
|
||
min-width: 0; flex: 1;
|
||
}
|
||
.h2-ops-cell-label {
|
||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: 0.06em; color: #8a8a86;
|
||
}
|
||
.h2-ops-cell-value {
|
||
font-size: 22px; font-weight: 600; letter-spacing: -0.01em;
|
||
color: #1a1a18; line-height: 1.2;
|
||
font-variant-numeric: tabular-nums;
|
||
margin-top: 2px;
|
||
}
|
||
.h2-ops-cell-target {
|
||
display: flex; align-items: center; gap: 5px;
|
||
margin-top: 3px;
|
||
font-size: 11px; color: var(--h2-muted);
|
||
}
|
||
|
||
/* ---- Milestone card (modernised) ---- */
|
||
.h2-milestone-card {
|
||
background: rgba(1, 105, 111, 0.035);
|
||
border: 1px solid rgba(1, 105, 111, 0.08);
|
||
border-radius: 12px;
|
||
padding: 12px 18px;
|
||
}
|
||
.h2-milestone-top {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
gap: 12px; flex-wrap: wrap;
|
||
}
|
||
.h2-milestone-badge {
|
||
display: flex; align-items: center; justify-content: center;
|
||
width: 28px; height: 28px; border-radius: 8px;
|
||
background: color-mix(in srgb, var(--ms-color) 10%, transparent);
|
||
color: var(--ms-color);
|
||
}
|
||
.h2-milestone-status {
|
||
font-size: 13px; font-weight: 600; display: block; line-height: 1.2;
|
||
}
|
||
.h2-milestone-tag {
|
||
font-size: 10px; font-weight: 500; text-transform: uppercase;
|
||
letter-spacing: 0.06em; color: #8a8a86; display: block;
|
||
}
|
||
.h2-milestone-bar-wrap {
|
||
display: flex; align-items: center; gap: 8px;
|
||
min-width: 120px; flex: 0 1 200px;
|
||
}
|
||
.h2-milestone-bar-track {
|
||
flex: 1; height: 5px; border-radius: 3px;
|
||
background: rgba(0, 0, 0, 0.06);
|
||
overflow: hidden;
|
||
}
|
||
.h2-milestone-bar-fill {
|
||
height: 100%; border-radius: 3px;
|
||
transition: width 600ms cubic-bezier(0.22, 1, 0.36, 1);
|
||
}
|
||
.h2-milestone-bar-pct {
|
||
font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||
min-width: 32px; text-align: right;
|
||
}
|
||
|
||
/* Metric pills row */
|
||
.h2-milestone-metrics {
|
||
display: flex; flex-wrap: wrap; gap: 6px;
|
||
flex: 1; min-width: 0;
|
||
}
|
||
.h2-milestone-pill {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
background: rgba(255, 255, 255, 0.6);
|
||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||
font-size: 12px;
|
||
}
|
||
.h2-milestone-pill-label {
|
||
font-weight: 500; color: #8a8a86;
|
||
}
|
||
.h2-milestone-pill-val {
|
||
font-weight: 700; color: #1a1a18;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.h2-milestone-pill-sep { color: #ccc; font-weight: 300; }
|
||
.h2-milestone-pill-target {
|
||
color: #8a8a86; font-variant-numeric: tabular-nums;
|
||
}
|
||
.h2-milestone-pill-pct {
|
||
font-size: 10px; font-weight: 600; padding: 1px 5px;
|
||
border-radius: 9999px; margin-left: 1px;
|
||
}
|
||
.h2-milestone-pill-pct--good {
|
||
background: rgba(15, 123, 95, 0.08); color: #0f7b5f;
|
||
}
|
||
.h2-milestone-pill-pct--close {
|
||
background: rgba(194, 123, 26, 0.08); color: #c27b1a;
|
||
}
|
||
.h2-milestone-pill-pct--behind {
|
||
background: rgba(193, 56, 56, 0.08); color: #c13838;
|
||
}
|
||
|
||
/* ---- Ops status dot ---- */
|
||
.h2-ops-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
/* ---- Ops rail config button ---- */
|
||
.h2-ops-rail-wrap { position: relative; }
|
||
.h2-ops-config-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px; height: 28px;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
background: #ffffff;
|
||
color: #8a8a86;
|
||
cursor: pointer;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.08);
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-ops-config-btn:hover {
|
||
color: #01696f;
|
||
border-color: rgba(1,105,111,0.2);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||
}
|
||
|
||
/* ---- Ops config popover ---- */
|
||
.h2-ops-config-popover {
|
||
position: absolute;
|
||
top: calc(100% + 8px);
|
||
right: 0;
|
||
width: 300px;
|
||
max-height: 480px;
|
||
overflow-y: auto;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
background: #ffffff;
|
||
box-shadow: 0 8px 30px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06);
|
||
z-index: 30;
|
||
}
|
||
.h2-ops-config-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px 0;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text-primary);
|
||
}
|
||
.h2-ops-config-reset {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: #01696f;
|
||
background: none; border: none; cursor: pointer;
|
||
padding: 2px 6px; border-radius: 4px;
|
||
transition: background 150ms ease;
|
||
}
|
||
.h2-ops-config-reset:hover { background: rgba(1,105,111,0.06); }
|
||
.h2-ops-config-hint {
|
||
padding: 4px 16px 8px;
|
||
font-size: 11px;
|
||
color: #8a8a86;
|
||
}
|
||
.h2-ops-config-list {
|
||
max-height: 320px;
|
||
overflow-y: auto;
|
||
list-style: none;
|
||
margin: 0; padding: 0;
|
||
}
|
||
.h2-ops-config-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 16px;
|
||
cursor: pointer;
|
||
transition: background 100ms ease;
|
||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||
}
|
||
.h2-ops-config-item:last-child { border-bottom: none; }
|
||
.h2-ops-config-item:hover { background: rgba(0,0,0,0.02); }
|
||
.h2-ops-config-item-on { background: rgba(1,105,111,0.03); }
|
||
.h2-ops-config-item-on:hover { background: rgba(1,105,111,0.05); }
|
||
.h2-ops-config-check {
|
||
width: 18px; height: 18px;
|
||
border-radius: 5px;
|
||
border: 1.5px solid rgba(0,0,0,0.15);
|
||
display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-ops-config-item-on .h2-ops-config-check {
|
||
background: #01696f;
|
||
border-color: #01696f;
|
||
color: #ffffff;
|
||
}
|
||
.h2-ops-config-dot {
|
||
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
|
||
}
|
||
.h2-ops-config-footer {
|
||
padding: 8px 16px;
|
||
border-top: 1px solid rgba(0,0,0,0.06);
|
||
font-size: 10px;
|
||
color: #8a8a86;
|
||
text-align: center;
|
||
}
|
||
|
||
/* ---- Sent Quotes widget ---- */
|
||
.h2-qs-select {
|
||
appearance: none;
|
||
padding: 4px 24px 4px 8px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
background: #ffffff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%238a8a86' stroke-width='1.2' stroke-linecap='round'/%3E%3C/svg%3E") no-repeat right 8px center;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--text-primary);
|
||
cursor: pointer;
|
||
transition: border-color 150ms ease;
|
||
min-width: 0;
|
||
}
|
||
.h2-qs-select:hover { border-color: rgba(0,0,0,0.15); }
|
||
.h2-qs-select:focus { outline: none; border-color: #01696f; }
|
||
|
||
.h2-qs-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 16px;
|
||
transition: background 100ms ease;
|
||
}
|
||
.h2-qs-row:hover { background: rgba(0,0,0,0.015); }
|
||
.h2-qs-row-border { border-bottom: 1px solid rgba(0,0,0,0.04); }
|
||
|
||
.h2-qs-lob-icon {
|
||
width: 32px; height: 32px;
|
||
border-radius: 8px;
|
||
background: rgba(1,105,111,0.06);
|
||
color: #01696f;
|
||
display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.h2-qs-party {
|
||
font-size: 9px; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 1px 5px; border-radius: 4px;
|
||
background: rgba(0,0,0,0.04); color: #8a8a86;
|
||
}
|
||
.h2-qs-sep {
|
||
width: 3px; height: 3px; border-radius: 50%;
|
||
background: rgba(0,0,0,0.15); flex-shrink: 0;
|
||
}
|
||
/* Status badges */
|
||
.h2-qs-sent {
|
||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(0,0,0,0.04); color: #8a8a86;
|
||
}
|
||
.h2-qs-viewed {
|
||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(1,105,111,0.08); color: #01696f;
|
||
}
|
||
.h2-qs-accepted {
|
||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(15,123,95,0.08); color: #0f7b5f;
|
||
}
|
||
.h2-qs-expired {
|
||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(194,123,26,0.08); color: #964219;
|
||
}
|
||
.h2-qs-declined {
|
||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(193,56,56,0.08); color: #c13838;
|
||
}
|
||
|
||
/* ---- Section headers ---- */
|
||
.h2-section-header { }
|
||
.h2-section-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.01em;
|
||
color: #1a1a18;
|
||
}
|
||
.h2-section-sub {
|
||
margin-top: 2px;
|
||
font-size: 13px;
|
||
color: #8a8a86;
|
||
}
|
||
|
||
/* ---- Card headers (icon + title) ---- */
|
||
.h2-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 20px 20px 16px;
|
||
border-bottom: 1px solid var(--h2-border);
|
||
}
|
||
.h2-icon-box {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border-radius: 8px;
|
||
background: rgba(1, 105, 111, 0.06);
|
||
color: var(--h2-accent);
|
||
}
|
||
.h2-icon-box-error {
|
||
background: color-mix(in srgb, var(--h2-error) 10%, transparent);
|
||
color: var(--h2-error);
|
||
}
|
||
|
||
/* ---- Lists ---- */
|
||
.h2-list {
|
||
padding: 0;
|
||
list-style: none;
|
||
margin: 0;
|
||
}
|
||
|
||
/* ---- Alert rows ---- */
|
||
.h2-alert-row {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.5rem;
|
||
padding: 0.75rem 0.75rem 0.75rem 1rem;
|
||
border-radius: 8px;
|
||
transition: background 150ms ease;
|
||
}
|
||
|
||
/* ---- Ops cell borders (internal vertical dividers) ---- */
|
||
.h2-cell-border {
|
||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||
}
|
||
@media (max-width: 639px) {
|
||
.h2-cell-border:nth-child(2n) { border-right: none; }
|
||
.h2-cell-border { border-bottom: 1px solid rgba(0, 0, 0, 0.06); }
|
||
}
|
||
|
||
/* ---- Tone classes ---- */
|
||
.h2-tone-pos { color: var(--h2-success); }
|
||
.h2-tone-neg { color: var(--h2-error); }
|
||
.h2-tone-neutral { color: var(--h2-muted); }
|
||
|
||
/* ---- Badges ---- */
|
||
.h2-badge-success {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
border-radius: 9999px;
|
||
padding: 0.125rem 0.625rem;
|
||
font-size: 0.6875rem;
|
||
font-weight: 600;
|
||
background: color-mix(in srgb, var(--h2-success) 10%, transparent);
|
||
color: var(--h2-success);
|
||
border: 1px solid color-mix(in srgb, var(--h2-success) 20%, transparent);
|
||
}
|
||
|
||
/* ---- Buttons ---- */
|
||
.h2-btn-primary {
|
||
background: var(--brand) !important;
|
||
color: #fff !important;
|
||
border-radius: 10px !important;
|
||
border: none !important;
|
||
box-shadow: 0 1px 3px color-mix(in srgb, var(--brand) 30%, transparent), 0 1px 2px color-mix(in srgb, var(--brand) 18%, transparent) !important;
|
||
transition: background 150ms ease, box-shadow 150ms ease !important;
|
||
}
|
||
.h2-btn-primary:hover {
|
||
background: var(--brand-hover) !important;
|
||
box-shadow: 0 2px 6px color-mix(in srgb, var(--brand) 35%, transparent), 0 1px 3px color-mix(in srgb, var(--brand) 22%, transparent) !important;
|
||
}
|
||
.h2-btn-outline {
|
||
border-radius: 10px !important;
|
||
border-color: var(--h2-border-strong) !important;
|
||
color: var(--h2-fg) !important;
|
||
}
|
||
|
||
/* ---- Accent icon tile ---- */
|
||
.h2-accent-icon {
|
||
background: rgba(1, 105, 111, 0.06);
|
||
color: var(--brand);
|
||
border: 1px solid rgba(1, 105, 111, 0.1);
|
||
}
|
||
|
||
/* ---- Accent text ---- */
|
||
.h2-text-accent { color: var(--brand); }
|
||
.h2-text-accent-hover { color: var(--brand-hover); }
|
||
|
||
/* ---- Calendar summary widget ---- */
|
||
.cal-event-list { display: flex; flex-direction: column; }
|
||
.cal-event-row {
|
||
display: flex; align-items: flex-start; gap: 10px;
|
||
padding: 12px 20px; border-bottom: 1px solid rgba(0,0,0,0.04);
|
||
transition: background 100ms ease;
|
||
}
|
||
.cal-event-row:last-child { border-bottom: none; }
|
||
.cal-event-row:hover { background: rgba(0,0,0,0.015); }
|
||
.cal-event-urgent { background: rgba(193,56,56,0.02); }
|
||
.cal-event-time {
|
||
width: 60px; flex-shrink: 0; font-size: 12px; font-weight: 500;
|
||
color: var(--text-muted); font-variant-numeric: tabular-nums; padding-top: 1px;
|
||
}
|
||
.cal-event-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
|
||
.cal-event-type-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 2px; opacity: 0.4; }
|
||
.cal-urgent-badge {
|
||
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(193,56,56,0.08); color: #c13838; flex-shrink: 0;
|
||
}
|
||
.cal-summary-footer {
|
||
padding: 10px 20px; border-top: 1px solid rgba(0,0,0,0.04); text-align: center;
|
||
}
|
||
|
||
/* ---- Notes widget (compact expandable cards) ---- */
|
||
.h2-sticky-add {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-sticky-add:hover { border-color: rgba(1,105,111,0.2); color: #01696f; }
|
||
|
||
.h2-notes-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
position: relative;
|
||
}
|
||
|
||
.h2-note-card {
|
||
display: flex;
|
||
border-radius: 8px;
|
||
border: 1px solid rgba(0,0,0,0.06);
|
||
background: #fff;
|
||
overflow: hidden;
|
||
transition: all 200ms ease;
|
||
}
|
||
.h2-note-card:hover { border-color: rgba(0,0,0,0.1); }
|
||
.h2-note-expanded {
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||
border-color: rgba(0,0,0,0.1);
|
||
flex-direction: column;
|
||
}
|
||
|
||
.h2-note-accent {
|
||
width: 3px;
|
||
flex-shrink: 0;
|
||
border-radius: 3px 0 0 3px;
|
||
}
|
||
.h2-note-expanded .h2-note-accent {
|
||
width: 100%;
|
||
height: 3px;
|
||
border-radius: 3px 3px 0 0;
|
||
}
|
||
|
||
/* Collapsed row */
|
||
.h2-note-collapsed {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px 8px 10px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
cursor: pointer;
|
||
transition: background 100ms ease;
|
||
}
|
||
.h2-note-collapsed:hover { background: rgba(0,0,0,0.01); }
|
||
|
||
.h2-note-preview {
|
||
flex: 1;
|
||
font-size: 12px;
|
||
color: var(--text-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
|
||
.h2-note-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
flex-shrink: 0;
|
||
opacity: 0;
|
||
transition: opacity 150ms ease;
|
||
}
|
||
.h2-note-collapsed:hover .h2-note-actions,
|
||
.group\/note:hover .h2-note-actions { opacity: 1; }
|
||
|
||
.h2-note-action {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px; height: 22px;
|
||
border-radius: 5px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #8a8a86;
|
||
cursor: pointer;
|
||
transition: all 120ms ease;
|
||
}
|
||
.h2-note-action:hover { background: rgba(0,0,0,0.05); color: var(--text-primary); }
|
||
.h2-note-action-danger:hover { color: #c13838; background: rgba(193,56,56,0.06); }
|
||
.h2-note-action-color { padding: 0; }
|
||
|
||
.h2-note-colordot {
|
||
width: 10px; height: 10px;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
}
|
||
|
||
/* Expanded body */
|
||
.h2-note-body {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.h2-note-body-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 6px 8px;
|
||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||
}
|
||
|
||
.h2-note-colordot-pick {
|
||
width: 14px; height: 14px;
|
||
border-radius: 50%;
|
||
border: 2px solid transparent;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-note-colordot-pick:hover { transform: scale(1.15); }
|
||
.h2-note-colordot-active { border-color: rgba(0,0,0,0.3); box-shadow: 0 0 0 1px #fff inset; }
|
||
|
||
.h2-note-textarea {
|
||
display: block;
|
||
width: 100%;
|
||
min-height: 80px;
|
||
padding: 10px 12px;
|
||
border: none;
|
||
outline: none;
|
||
resize: vertical;
|
||
font-size: 12px;
|
||
line-height: 1.65;
|
||
color: var(--text-primary);
|
||
background: transparent;
|
||
font-family: inherit;
|
||
}
|
||
.h2-note-textarea::placeholder { color: var(--text-muted); opacity: 0.5; }
|
||
|
||
/* ---- Chart toolbar ---- */
|
||
.h2-chart-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 12px 16px;
|
||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||
flex-wrap: wrap;
|
||
}
|
||
.h2-chart-metric-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 4px 8px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: background 150ms ease;
|
||
}
|
||
.h2-chart-metric-btn:hover { background: rgba(0,0,0,0.03); }
|
||
|
||
.h2-chart-metric-menu {
|
||
position: absolute;
|
||
top: calc(100% + 4px);
|
||
left: 0;
|
||
z-index: 30;
|
||
width: 220px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
background: #fff;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||
overflow: hidden;
|
||
padding: 4px 0;
|
||
}
|
||
.h2-chart-metric-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
transition: background 100ms ease;
|
||
}
|
||
.h2-chart-metric-item:hover { background: rgba(0,0,0,0.03); }
|
||
.h2-chart-metric-item-on { background: rgba(1,105,111,0.04); font-weight: 600; }
|
||
|
||
.h2-chart-sep {
|
||
width: 1px;
|
||
height: 16px;
|
||
background: rgba(0,0,0,0.08);
|
||
}
|
||
.h2-chart-type-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 4px 6px;
|
||
border-radius: 4px;
|
||
border: none;
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
background: transparent;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
}
|
||
|
||
/* ---- Pipeline stage bars ---- */
|
||
.h2-pipeline-stage {}
|
||
.h2-pipeline-bar {
|
||
height: 6px;
|
||
border-radius: 3px;
|
||
background: rgba(0,0,0,0.04);
|
||
overflow: hidden;
|
||
}
|
||
.h2-pipeline-bar-fill {
|
||
height: 100%;
|
||
border-radius: 3px;
|
||
transition: width 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
/* ---- Tasks widget interactivity ---- */
|
||
.h2-task-add-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px; height: 28px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
background: transparent;
|
||
color: #8a8a86;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-task-add-btn:hover { background: rgba(1,105,111,0.06); color: #01696f; border-color: rgba(1,105,111,0.2); }
|
||
|
||
.h2-task-progress {
|
||
height: 2px;
|
||
background: rgba(0,0,0,0.04);
|
||
overflow: hidden;
|
||
}
|
||
.h2-task-progress-fill {
|
||
height: 100%;
|
||
background: #01696f;
|
||
border-radius: 1px;
|
||
transition: width 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
.h2-task-add-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
border-bottom: 1px solid rgba(0,0,0,0.04);
|
||
overflow: hidden;
|
||
}
|
||
.h2-task-add-input {
|
||
flex: 1;
|
||
padding: 5px 8px;
|
||
border-radius: 6px;
|
||
border: 1px solid rgba(0,0,0,0.1);
|
||
font-size: 12px;
|
||
color: var(--text-primary);
|
||
outline: none;
|
||
transition: border-color 150ms ease;
|
||
}
|
||
.h2-task-add-input:focus { border-color: #01696f; }
|
||
.h2-task-add-submit {
|
||
padding: 5px 10px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
background: #01696f;
|
||
color: #fff;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 150ms ease;
|
||
}
|
||
.h2-task-add-submit:hover { background: #015a5f; }
|
||
.h2-task-add-submit:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
|
||
.h2-task-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid rgba(0,0,0,0.03);
|
||
transition: background 100ms ease;
|
||
}
|
||
.h2-task-item:hover { background: rgba(0,0,0,0.01); }
|
||
.h2-task-item:last-child { border-bottom: none; }
|
||
|
||
.h2-task-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px; height: 18px;
|
||
border-radius: 5px;
|
||
border: 1.5px solid rgba(0,0,0,0.18);
|
||
background: transparent;
|
||
color: transparent;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
margin-top: 1px;
|
||
transition: all 200ms ease;
|
||
}
|
||
.h2-task-checkbox:hover { border-color: #01696f; }
|
||
.h2-task-checkbox-done {
|
||
background: #01696f;
|
||
border-color: #01696f;
|
||
color: #fff;
|
||
}
|
||
|
||
.h2-task-text {
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
transition: all 200ms ease;
|
||
}
|
||
.h2-task-done {
|
||
text-decoration: line-through;
|
||
color: #b0b0ac !important;
|
||
font-weight: 400 !important;
|
||
}
|
||
|
||
.h2-task-remove {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 20px; height: 20px;
|
||
border-radius: 4px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #c13838;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
flex-shrink: 0;
|
||
transition: all 150ms ease;
|
||
}
|
||
.group\/task:hover .h2-task-remove { opacity: 0.5; }
|
||
.h2-task-remove:hover { opacity: 1 !important; background: rgba(193,56,56,0.06); }
|
||
|
||
/* ---- Alert dismiss ---- */
|
||
.h2-alert-dismiss {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 22px; height: 22px;
|
||
border-radius: 5px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #8a8a86;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
flex-shrink: 0;
|
||
transition: all 150ms ease;
|
||
}
|
||
.group\/alert:hover .h2-alert-dismiss { opacity: 0.6; }
|
||
.h2-alert-dismiss:hover { opacity: 1 !important; background: rgba(0,0,0,0.05); }
|
||
|
||
.h2-alert-restore {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: #01696f;
|
||
background: none;
|
||
border: none;
|
||
cursor: pointer;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
transition: background 150ms ease;
|
||
}
|
||
.h2-alert-restore:hover { background: rgba(1,105,111,0.06); }
|
||
|
||
/* ---- Milestone config ---- */
|
||
.h2-milestone-bottom {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
.h2-mile-config-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px; height: 24px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
background: rgba(0,0,0,0.03);
|
||
color: #8a8a86;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
flex-shrink: 0;
|
||
}
|
||
.h2-mile-config-btn:hover { background: rgba(0,0,0,0.06); color: #01696f; }
|
||
|
||
.h2-mile-popover {
|
||
position: absolute;
|
||
top: calc(100% + 6px);
|
||
right: 0;
|
||
z-index: 30;
|
||
width: 260px;
|
||
border-radius: 10px;
|
||
border: 1px solid rgba(0,0,0,0.08);
|
||
background: #fff;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||
overflow: hidden;
|
||
}
|
||
.h2-mile-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
cursor: pointer;
|
||
transition: background 100ms ease;
|
||
}
|
||
.h2-mile-item:hover { background: rgba(0,0,0,0.02); }
|
||
.h2-mile-item-on { background: rgba(1,105,111,0.03); }
|
||
.h2-mile-check {
|
||
width: 16px; height: 16px;
|
||
border-radius: 4px;
|
||
border: 1.5px solid rgba(0,0,0,0.15);
|
||
display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-mile-check-on {
|
||
background: #01696f;
|
||
border-color: #01696f;
|
||
color: #fff;
|
||
}
|
||
|
||
/* ---- Chart range toggle ---- */
|
||
.h2-chart-range {
|
||
display: inline-flex;
|
||
gap: 1px;
|
||
padding: 2px;
|
||
border-radius: 6px;
|
||
background: rgba(0,0,0,0.04);
|
||
}
|
||
.h2-chart-range-btn {
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
border: none;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 150ms ease;
|
||
color: var(--text-muted);
|
||
background: transparent;
|
||
}
|
||
.h2-chart-range-on {
|
||
background: #fff;
|
||
color: var(--text-primary);
|
||
box-shadow: 0 1px 2px rgba(0,0,0,0.06);
|
||
}
|
||
|
||
/* ---- Card hover state ---- */
|
||
.h2-card-hover:hover {
|
||
box-shadow: var(--card-shadow-hover);
|
||
border-color: color-mix(in srgb, var(--brand) 25%, transparent);
|
||
}
|
||
|
||
/* ======== Quick Leads widget ======== */
|
||
.h2-ql-view-all {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #01696f;
|
||
text-decoration: none;
|
||
transition: opacity 150ms ease;
|
||
}
|
||
.h2-ql-view-all:hover { opacity: 0.7; }
|
||
|
||
.h2-ql-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 24px 12px;
|
||
text-align: center;
|
||
}
|
||
.h2-ql-add-link {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: #01696f;
|
||
text-decoration: none;
|
||
}
|
||
.h2-ql-add-link:hover { text-decoration: underline; }
|
||
|
||
.h2-ql-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 14px;
|
||
width: 100%;
|
||
align-self: stretch;
|
||
transition: background 100ms ease;
|
||
}
|
||
.h2-ql-row:hover { background: rgba(0,0,0,0.015); }
|
||
.h2-ql-row-border {
|
||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||
}
|
||
.h2-ql-sep {
|
||
width: 3px; height: 3px; border-radius: 50%;
|
||
background: rgba(0,0,0,0.15); flex-shrink: 0;
|
||
}
|
||
.h2-ql-source {
|
||
display: inline-flex; align-items: center; gap: 3px;
|
||
font-size: 11px; font-weight: 500;
|
||
color: #01696f;
|
||
}
|
||
|
||
.h2-ql-avatar {
|
||
width: 26px; height: 26px;
|
||
border-radius: 7px;
|
||
background: rgba(194,123,26,0.08);
|
||
color: #c27b1a;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.h2-ql-quote-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 24px; height: 24px;
|
||
border-radius: 6px;
|
||
background: rgba(1,105,111,0.06);
|
||
color: #01696f;
|
||
flex-shrink: 0;
|
||
transition: all 150ms ease;
|
||
margin-left: auto;
|
||
}
|
||
.h2-ql-quote-btn:hover { background: rgba(1,105,111,0.14); }
|
||
|
||
.h2-ql-pri-urgent {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(193,56,56,0.08); color: #c13838; white-space: nowrap;
|
||
}
|
||
.h2-ql-pri-high {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap;
|
||
}
|
||
.h2-ql-pri-normal {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap;
|
||
}
|
||
|
||
/* ======== Sales Leads widget ======== */
|
||
.h2-sl-filter-pill {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 3px 10px; border-radius: 9999px;
|
||
font-size: 11px; font-weight: 500; white-space: nowrap;
|
||
border: 1px solid rgba(0,0,0,0.08); background: transparent;
|
||
color: var(--h2-muted); cursor: pointer;
|
||
transition: all 120ms ease;
|
||
}
|
||
.h2-sl-filter-pill:hover { background: rgba(0,0,0,0.03); }
|
||
.h2-sl-filter-pill--active {
|
||
background: rgba(1,105,111,0.08); color: #01696f;
|
||
border-color: rgba(1,105,111,0.2); font-weight: 600;
|
||
}
|
||
.h2-sl-filter-count {
|
||
font-size: 9px; font-weight: 700; min-width: 14px; text-align: center;
|
||
padding: 0 3px; border-radius: 9999px;
|
||
background: rgba(0,0,0,0.05); color: var(--h2-muted);
|
||
}
|
||
.h2-sl-filter-pill--active .h2-sl-filter-count {
|
||
background: rgba(1,105,111,0.12); color: #01696f;
|
||
}
|
||
|
||
.h2-sl-header {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 6px 14px; font-size: 10px; font-weight: 600;
|
||
text-transform: uppercase; letter-spacing: 0.04em;
|
||
color: var(--h2-muted); border-bottom: 1px solid rgba(0,0,0,0.06);
|
||
background: rgba(0,0,0,0.015);
|
||
}
|
||
|
||
.h2-sl-row {
|
||
display: flex; align-items: center; gap: 8px;
|
||
padding: 7px 14px; transition: background 100ms ease;
|
||
}
|
||
.h2-sl-row:hover { background: rgba(0,0,0,0.015); }
|
||
|
||
.h2-sl-empty {
|
||
display: flex; flex-direction: column; align-items: center;
|
||
gap: 4px; padding: 20px 12px; text-align: center;
|
||
font-size: 12px; color: var(--h2-muted);
|
||
}
|
||
|
||
.h2-sl-source-chip {
|
||
display: inline-flex; align-items: center; gap: 3px;
|
||
font-size: 10px; font-weight: 500; color: #01696f;
|
||
padding: 1px 6px; border-radius: 9999px;
|
||
background: rgba(1,105,111,0.06);
|
||
}
|
||
|
||
.h2-sl-status-new {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(59,130,246,0.08); color: #3b82f6; white-space: nowrap;
|
||
}
|
||
.h2-sl-status-contacted {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(1,105,111,0.08); color: #01696f; white-space: nowrap;
|
||
}
|
||
.h2-sl-status-qualified {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(194,123,26,0.08); color: #c27b1a; white-space: nowrap;
|
||
}
|
||
.h2-sl-status-proposal {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(139,92,246,0.08); color: #8b5cf6; white-space: nowrap;
|
||
}
|
||
.h2-sl-status-won {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(15,123,95,0.08); color: #0f7b5f; white-space: nowrap;
|
||
}
|
||
.h2-sl-status-lost {
|
||
font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 9999px;
|
||
background: rgba(0,0,0,0.04); color: #8a8a86; white-space: nowrap;
|
||
}
|
||
|
||
/* ── Client favorites widget ── */
|
||
.h2-fav-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.h2-fav-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 14px;
|
||
border-radius: 10px;
|
||
border: 1px solid var(--h2-border);
|
||
background: var(--h2-surface);
|
||
text-decoration: none;
|
||
color: inherit;
|
||
transition: all 150ms ease;
|
||
cursor: pointer;
|
||
}
|
||
.h2-fav-card:hover {
|
||
border-color: rgba(1,105,111,0.2);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||
transform: translateY(-1px);
|
||
}
|
||
.h2-fav-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.h2-fav-avatar {
|
||
width: 32px; height: 32px;
|
||
border-radius: 8px;
|
||
background: rgba(1,105,111,0.08);
|
||
color: #01696f;
|
||
font-size: 11px; font-weight: 700;
|
||
display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.h2-fav-star {
|
||
width: 22px; height: 22px;
|
||
border-radius: 4px;
|
||
border: none;
|
||
background: transparent;
|
||
color: #f59e0b;
|
||
cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
opacity: 0;
|
||
transition: all 150ms ease;
|
||
}
|
||
.h2-fav-card:hover .h2-fav-star { opacity: 1; }
|
||
.h2-fav-star:hover { background: rgba(245,158,11,0.1); }
|
||
.h2-fav-name {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--h2-fg);
|
||
line-height: 1.3;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.h2-fav-tier {
|
||
display: inline-block;
|
||
font-size: 9px;
|
||
font-weight: 700;
|
||
padding: 1px 6px;
|
||
border-radius: 9999px;
|
||
white-space: nowrap;
|
||
width: fit-content;
|
||
}
|
||
.h2-fav-tier-customer { background: rgba(1,105,111,0.08); color: #01696f; }
|
||
.h2-fav-tier-lead { background: rgba(147,51,234,0.08); color: #9333ea; }
|
||
.h2-fav-tier-ql { background: rgba(194,123,26,0.08); color: #c27b1a; }
|
||
.h2-fav-tier-cancelled { background: rgba(0,0,0,0.04); color: #8a8a86; }
|
||
.h2-fav-stats {
|
||
display: flex;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
color: var(--h2-fg);
|
||
font-weight: 500;
|
||
}
|
||
.h2-fav-meta {
|
||
display: flex;
|
||
gap: 6px;
|
||
font-size: 10px;
|
||
color: var(--h2-muted);
|
||
}
|
||
</style>
|