Files
policy-ui/app/pages/index.vue
Jordan Weingarten 67482f6629 WIP jordan
2026-04-16 11:11:44 -05:00

3362 lines
134 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 27 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 (14 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 &rarr;
</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 &rarr;
</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 &amp; 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>