778 lines
35 KiB
Vue
778 lines
35 KiB
Vue
<script setup lang="ts">
|
|
import type { WelcomeDashboardKpi } from '~/types/welcome-dashboard'
|
|
import {
|
|
DASHBOARD_PRESET_ORDER,
|
|
DASHBOARD_ROLE_PRESETS,
|
|
DASHBOARD_WIDGETS,
|
|
type DashboardRolePresetId,
|
|
type DashboardWidgetId
|
|
} from '~/composables/useDashboardHomeWidgets'
|
|
|
|
const { saved: homeBranding } = useBrokerageBranding()
|
|
const welcome = useWelcomeDashboard()
|
|
|
|
const {
|
|
widgets,
|
|
widgetOrder,
|
|
layoutUnlocked,
|
|
activePreset,
|
|
isPresetDirty,
|
|
applyPreset,
|
|
setWidget,
|
|
reapplySelectedPreset,
|
|
reorderWidgets
|
|
} = useDashboardHomeWidgets()
|
|
|
|
const dashConfigOpen = ref(false)
|
|
const draggingWidget = ref<DashboardWidgetId | null>(null)
|
|
|
|
function onDragStart(wid: DashboardWidgetId, e: DragEvent) {
|
|
if (!layoutUnlocked.value) { e.preventDefault(); return }
|
|
draggingWidget.value = wid
|
|
try { e.dataTransfer?.setData('text/plain', wid); if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move' } catch { /* */ }
|
|
}
|
|
function onDragEnd() { draggingWidget.value = null }
|
|
function onDropSection(target: DashboardWidgetId, e: DragEvent) {
|
|
e.preventDefault()
|
|
if (!layoutUnlocked.value) return
|
|
const raw = draggingWidget.value ?? e.dataTransfer?.getData('text/plain')
|
|
const from = raw as DashboardWidgetId | undefined
|
|
if (!from || from === target) return
|
|
reorderWidgets(from, target)
|
|
draggingWidget.value = null
|
|
}
|
|
function toggleLayoutUnlock() { layoutUnlocked.value = !layoutUnlocked.value }
|
|
|
|
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`
|
|
}
|
|
|
|
/* ---- GWP chart ---- */
|
|
const gwpTrend = [
|
|
{ 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' }
|
|
] as const
|
|
const gwpChartLayout = { viewW: 400, viewH: 152, padX: 8, padY: 14 } as const
|
|
const gwpChartModel = computed(() => {
|
|
const pts = gwpTrend.map((b) => b.v)
|
|
const { viewW, viewH, padX, padY } = gwpChartLayout
|
|
const innerW = viewW - padX * 2; const innerH = viewH - padY * 2
|
|
const maxV = Math.max(...pts, 1); const span = maxV || 1
|
|
const points = pts.map((p, i) => ({
|
|
x: padX + (i / Math.max(1, pts.length - 1)) * innerW,
|
|
y: padY + (1 - p / span) * innerH, v: p
|
|
}))
|
|
const bottomY = padY + innerH; const first = points[0]!; const last = points[points.length - 1]!
|
|
let areaD = `M ${first.x},${bottomY} L ${first.x},${first.y}`
|
|
let lineD = `M ${first.x},${first.y}`
|
|
for (let i = 1; i < points.length; i++) { areaD += ` L ${points[i]!.x},${points[i]!.y}`; lineD += ` L ${points[i]!.x},${points[i]!.y}` }
|
|
areaD += ` L ${last.x},${bottomY} Z`
|
|
const gridYs = [0, 0.5, 1].map((t) => padY + t * innerH)
|
|
return { areaPath: areaD, linePath: lineD, points, gridYs, viewW, viewH, padX, innerW }
|
|
})
|
|
const gwpLatest = computed(() => gwpTrend[gwpTrend.length - 1]!)
|
|
|
|
/* ---- 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` }
|
|
|
|
/* ---- 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 ---- */
|
|
const opsMetrics = [
|
|
{ id: 'prod', label: 'Production MTD', value: '$1.24M', target: '$1.18M', status: 'on-track' as const, icon: 'i-heroicons-banknotes' },
|
|
{ id: 'ren', label: 'Renewals due', value: '23', target: 'next 30d', status: 'attention' as const, icon: 'i-heroicons-arrow-path' },
|
|
{ id: 'coll', label: 'Collections at risk', value: '$184K', target: '3 accounts', status: 'warning' as const, icon: 'i-heroicons-exclamation-triangle' },
|
|
{ id: 'claims', label: 'Claims pending', value: '7', target: 'avg 4.2d open', status: 'neutral' as const, icon: 'i-heroicons-shield-exclamation' },
|
|
{ id: 'svc', label: 'Service backlog', value: '12', target: 'SLA: 94%', status: 'on-track' as const, icon: 'i-heroicons-inbox-stack' }
|
|
]
|
|
|
|
function opsStatusClass(status: 'on-track' | 'attention' | 'warning' | 'neutral') {
|
|
switch (status) {
|
|
case 'on-track': return 'h2-ops-on-track'
|
|
case 'attention': return 'h2-ops-attention'
|
|
case 'warning': return 'h2-ops-warning'
|
|
default: return 'h2-ops-neutral'
|
|
}
|
|
}
|
|
|
|
function opsStatusDotStyle(status: 'on-track' | 'attention' | 'warning' | 'neutral') {
|
|
switch (status) {
|
|
case 'on-track': return 'background:#0f7b5f'
|
|
case 'attention': return 'background:#0d5c63'
|
|
case 'warning': return 'background:#c27b1a'
|
|
default: return 'background:#8c857d'
|
|
}
|
|
}
|
|
|
|
/* ---- Quote lines ---- */
|
|
const quoteLines = [
|
|
{ to: '/quotes/auto', label: 'Auto', hint: 'Motor, fleet & bind', icon: 'i-heroicons-truck' },
|
|
{ to: '/quotes/health', label: 'Health', hint: 'Collective & individual', icon: 'i-heroicons-heart' },
|
|
{ to: '/quotes/life', label: 'Life', hint: 'Individual & corporate', icon: 'i-heroicons-user-group' },
|
|
{ to: '/quotes/general-risk', label: 'General risk', hint: 'Liability & specialty', icon: 'i-heroicons-building-office-2' },
|
|
{ to: '/quotes/custom', label: 'Custom', hint: 'Ad hoc products', icon: 'i-heroicons-puzzle-piece' }
|
|
] as const
|
|
|
|
/* ---- 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())
|
|
)
|
|
|
|
/* ---- 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']
|
|
</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>
|
|
<div class="flex gap-2">
|
|
<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="/quotes">
|
|
<UButton size="sm" color="primary" class="h2-btn-primary" icon="i-heroicons-document-text">
|
|
New quote
|
|
</UButton>
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Operations rail: 5-cell command strip -->
|
|
<div class="h2-card h2-card-flush grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5">
|
|
<div
|
|
v-for="(op, i) in opsMetrics"
|
|
:key="op.id"
|
|
class="relative flex items-start gap-3 p-4"
|
|
:class="[
|
|
i < opsMetrics.length - 1 ? 'h2-cell-border' : '',
|
|
opsStatusClass(op.status)
|
|
]"
|
|
>
|
|
<!-- Status rail (left edge accent) -->
|
|
<div class="absolute inset-y-2 left-0 rounded-full" :style="opsStatusDotStyle(op.status) + ';width:3px'" />
|
|
|
|
<div class="pl-2.5">
|
|
<div class="flex items-center gap-1.5">
|
|
<UIcon :name="op.icon" class="h-3.5 w-3.5 text-[var(--h2-muted)]" />
|
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ op.label }}</p>
|
|
</div>
|
|
<p class="mt-1 font-mono text-xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">{{ op.value }}</p>
|
|
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">{{ op.target }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ==================== MILESTONE ==================== -->
|
|
<template #milestone>
|
|
<div class="h2-card h2-rail-success flex flex-wrap items-center gap-4 px-5 py-3.5">
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex h-7 w-7 items-center justify-center rounded-lg bg-[var(--h2-success)]/10">
|
|
<UIcon name="i-heroicons-check-badge" class="h-4 w-4 text-[var(--h2-success)]" />
|
|
</div>
|
|
<span class="text-sm font-semibold text-[var(--h2-success)]">On track</span>
|
|
</div>
|
|
<div class="h-4 w-px bg-[var(--h2-border)]" />
|
|
<span class="text-sm text-[var(--h2-fg)]">
|
|
Premium <strong class="font-semibold">$1.24M</strong> vs $1.18M
|
|
</span>
|
|
<div class="h-4 w-px bg-[var(--h2-border)] hidden sm:block" />
|
|
<span class="text-sm text-[var(--h2-fg)] hidden sm:inline">
|
|
Policies <strong class="font-semibold">42</strong> / 40
|
|
</span>
|
|
<span class="ml-auto text-[11px] font-medium uppercase tracking-wider text-[var(--h2-muted)]">MTD plan</span>
|
|
</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 h2-rail-accent overflow-hidden p-4 transition-all duration-200 hover:shadow-md"
|
|
>
|
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">{{ k.label }}</p>
|
|
<div class="mt-1.5 flex items-end gap-2.5">
|
|
<p class="font-mono text-2xl font-bold tabular-nums tracking-tight text-[var(--h2-fg)]">
|
|
{{ 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-2.5 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-start">
|
|
<!-- Tasks -->
|
|
<div class="h2-card overflow-hidden">
|
|
<div class="h2-card-header">
|
|
<div class="h2-icon-box"><UIcon name="i-heroicons-clipboard-document-check" class="h-4 w-4" /></div>
|
|
<div>
|
|
<p class="text-sm font-semibold text-[var(--h2-fg)]">Today's tasks</p>
|
|
<p class="text-[11px] text-[var(--h2-muted)]">{{ welcome.dailyTasks.length }} items prioritized</p>
|
|
</div>
|
|
</div>
|
|
<ul class="h2-list">
|
|
<li
|
|
v-for="task in welcome.dailyTasks"
|
|
:key="task.id"
|
|
class="h2-list-item"
|
|
>
|
|
<div
|
|
class="mt-1 h-2 w-2 shrink-0 rounded-full"
|
|
:class="task.emphasis ? 'bg-[var(--h2-warning)]' : 'bg-[var(--h2-border)]'"
|
|
/>
|
|
<span
|
|
class="text-[13px] leading-snug"
|
|
:class="task.emphasis ? 'font-medium text-[var(--h2-fg)]' : 'text-[var(--h2-fg-secondary)]'"
|
|
>{{ task.title }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Alerts -->
|
|
<div class="h2-card overflow-hidden">
|
|
<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>
|
|
<p class="text-sm font-semibold text-[var(--h2-fg)]">Alerts</p>
|
|
<p class="text-[11px] text-[var(--h2-muted)]">Exceptions needing attention</p>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-px px-3 pb-3">
|
|
<div
|
|
v-for="alert in alertsWithMeta"
|
|
:key="alert.id"
|
|
class="h2-alert-row"
|
|
: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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ==================== CHARTS ==================== -->
|
|
<template #charts>
|
|
<section class="grid gap-4 lg:grid-cols-5" aria-labelledby="ch-h2">
|
|
<!-- GWP -->
|
|
<div class="lg:col-span-3 h2-card overflow-hidden">
|
|
<div class="flex flex-wrap items-start justify-between gap-3 px-5 pt-5">
|
|
<div>
|
|
<h2 id="ch-h2" class="text-sm font-semibold text-[var(--h2-fg)]">GWP written</h2>
|
|
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Trailing 6 months</p>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="h2-badge-success">+6.2%</span>
|
|
<span class="font-mono text-lg font-bold tabular-nums text-[var(--h2-fg)]">{{ gwpLatest.display }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="px-2 pb-2 pt-3">
|
|
<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 ${gwpChartModel.viewW} ${gwpChartModel.viewH}`" role="img">
|
|
<title>Gross written premium trend</title>
|
|
<defs>
|
|
<linearGradient id="gwp2" 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>
|
|
<line v-for="(gy, i) in gwpChartModel.gridYs" :key="i" class="stroke-[var(--h2-border)]" stroke-width="1"
|
|
:x1="gwpChartModel.padX" :y1="gy" :x2="gwpChartModel.padX + gwpChartModel.innerW" :y2="gy" />
|
|
<path :d="gwpChartModel.areaPath" fill="url(#gwp2)" />
|
|
<path :d="gwpChartModel.linePath" fill="none" stroke="var(--h2-accent)" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" />
|
|
<g v-for="(pt, i) in gwpChartModel.points" :key="i">
|
|
<circle :cx="pt.x" :cy="pt.y" r="4.5" fill="var(--h2-surface)" stroke="var(--h2-accent)" stroke-width="2" />
|
|
</g>
|
|
</svg>
|
|
<div class="flex justify-between border-t border-[var(--h2-border)] px-3 pb-2 pt-1.5">
|
|
<div v-for="row in gwpTrend" :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="px-5 pt-5">
|
|
<h2 class="text-sm font-semibold text-[var(--h2-fg)]">Pipeline</h2>
|
|
<p class="mt-0.5 text-[11px] text-[var(--h2-muted)]">Book, open quotes & YTD</p>
|
|
</div>
|
|
<!-- Summary trio -->
|
|
<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>
|
|
<!-- Segment mix -->
|
|
<div class="mt-4 flex-1 px-5 pb-5">
|
|
<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" :style="segColorStyles[i] + ';width:' + row.pct + '%'" />
|
|
</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">
|
|
<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>
|
|
</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 h2-rail-accent p-4 transition-all 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>
|
|
|
|
<!-- ==================== QUOTES LINE ==================== -->
|
|
<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">Quotes</h2>
|
|
<p class="h2-section-sub">Start a new quote by line of business</p>
|
|
</div>
|
|
<NuxtLink to="/quotes" class="text-sm font-semibold h2-text-accent transition hover:h2-text-accent-hover">
|
|
View all →
|
|
</NuxtLink>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
|
<NuxtLink v-for="line in quoteLines" :key="line.to" :to="line.to" class="group">
|
|
<div class="h2-card h2-card-hover flex h-full flex-col items-center justify-center px-4 py-5 text-center transition-all duration-200">
|
|
<div class="flex h-11 w-11 items-center justify-center rounded-xl h2-accent-icon transition-transform duration-200 group-hover:scale-105">
|
|
<UIcon :name="line.icon" class="h-5 w-5" />
|
|
</div>
|
|
<p class="mt-3 text-sm font-semibold text-[var(--h2-fg)]">{{ line.label }}</p>
|
|
<p class="mt-0.5 text-[11px] leading-snug text-[var(--h2-muted)]">{{ line.hint }}</p>
|
|
</div>
|
|
</NuxtLink>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<!-- ==================== QUICK LINKS ==================== -->
|
|
<template #quick_links>
|
|
<section class="space-y-4">
|
|
<div class="h2-section-header">
|
|
<h2 class="h2-section-title">Quick links</h2>
|
|
<p class="h2-section-sub">Jump to operational areas</p>
|
|
</div>
|
|
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
<NuxtLink
|
|
v-for="link in welcome.quickLinks" :key="link.to" :to="link.to"
|
|
class="group h2-card h2-card-hover flex gap-3.5 p-4 transition-all duration-200"
|
|
>
|
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl h2-accent-icon transition-transform duration-200 group-hover:scale-105">
|
|
<UIcon :name="link.icon" class="h-5 w-5" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<p class="font-semibold text-[var(--h2-fg)] transition-colors group-hover:h2-text-accent">{{ link.label }}</p>
|
|
<p class="mt-0.5 text-[12px] leading-snug text-[var(--h2-fg-secondary)]">{{ link.description }}</p>
|
|
</div>
|
|
</NuxtLink>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
</HomeDashboardWidgetBlocks>
|
|
|
|
<!-- Layout controls -->
|
|
<div class="mx-auto mt-12 flex max-w-6xl flex-col gap-3 border-t border-[var(--h2-border)] pt-6 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<UButton
|
|
:icon="layoutUnlocked ? 'i-heroicons-arrows-up-down' : 'i-heroicons-lock-closed'"
|
|
:color="layoutUnlocked ? 'primary' : 'neutral'" variant="soft" class="h2-btn-outline"
|
|
@click="toggleLayoutUnlock"
|
|
>{{ layoutUnlocked ? 'Reorder on' : 'Reorder off' }}</UButton>
|
|
<p class="max-w-md text-xs text-[var(--h2-muted)]">Drag blocks by the grip when reorder is on.</p>
|
|
</div>
|
|
<UButton icon="i-heroicons-squares-2x2" color="primary" class="h2-btn-primary shrink-0" @click="dashConfigOpen = true">
|
|
Layout & widgets
|
|
</UButton>
|
|
</div>
|
|
|
|
<!-- Slideover -->
|
|
<USlideover v-model:open="dashConfigOpen" side="right">
|
|
<template #content>
|
|
<div class="flex h-full max-w-md flex-col bg-[var(--h2-surface)] sm:max-w-lg">
|
|
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-[var(--h2-border)] p-6">
|
|
<div class="min-w-0">
|
|
<h2 class="text-lg font-semibold text-[var(--h2-fg)]">Dashboard layout</h2>
|
|
<p class="mt-1 text-sm text-[var(--h2-fg-secondary)]">Choose a role preset or toggle sections.</p>
|
|
</div>
|
|
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" class="shrink-0" aria-label="Close" @click="dashConfigOpen = false" />
|
|
</div>
|
|
<div class="min-h-0 flex-1 overflow-y-auto p-6">
|
|
<div class="space-y-6">
|
|
<div>
|
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Role preset</p>
|
|
<USelect :model-value="activePreset" :items="presetSelectItems" value-key="value" label-key="label" class="mt-2 w-full"
|
|
@update:model-value="applyPreset($event as DashboardRolePresetId)" />
|
|
<p class="mt-2 text-xs text-[var(--h2-muted)]">{{ activePresetHint }}</p>
|
|
<UButton v-if="isPresetDirty" size="xs" color="neutral" variant="soft" class="mt-2" @click="reapplySelectedPreset">Reset to preset</UButton>
|
|
</div>
|
|
<div class="border-t border-[var(--h2-border)] pt-4">
|
|
<p class="text-[11px] font-semibold uppercase tracking-wider text-[var(--h2-muted)]">Sections</p>
|
|
<ul class="mt-3 space-y-4">
|
|
<li v-for="w in DASHBOARD_WIDGETS" :key="w.id" class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-medium text-[var(--h2-fg)]">{{ w.label }}</p>
|
|
<p class="text-xs text-[var(--h2-muted)]">{{ w.description }}</p>
|
|
</div>
|
|
<USwitch :model-value="widgets[w.id]" @update:model-value="setWidget(w.id, $event)" />
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</USlideover>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* =====================================================================
|
|
HOME 2 — PETROLEUM / STONE DESIGN SYSTEM (scoped)
|
|
===================================================================== */
|
|
|
|
/* ---- Palette ---- */
|
|
.h2 {
|
|
/* Petroleum accent scale */
|
|
--h2-accent: #0d5c63;
|
|
--h2-accent-hover: #0a4a50;
|
|
--h2-accent-muted: #1a8a8a;
|
|
--h2-accent-soft: #0d5c63 / 0.08;
|
|
|
|
/* Surfaces — stone/warm-gray with real depth */
|
|
--h2-page-bg: var(--page-bg, #f4f2ef);
|
|
--h2-surface: #faf9f7;
|
|
--h2-surface-raised:#ffffff;
|
|
--h2-surface-inset: #f0eeeb;
|
|
|
|
/* Foregrounds */
|
|
--h2-fg: #1a1a1a;
|
|
--h2-fg-secondary: #5c5650;
|
|
--h2-muted: #8c857d;
|
|
|
|
/* Borders — two tiers for depth */
|
|
--h2-border: #e5e0da;
|
|
--h2-border-strong: #d5cfc8;
|
|
|
|
/* Semantic — assertive */
|
|
--h2-success: #0f7b5f;
|
|
--h2-warning: #c27b1a;
|
|
--h2-error: #c13838;
|
|
--h2-info: #0d5c63;
|
|
|
|
background: var(--h2-page-bg);
|
|
}
|
|
|
|
/* ---- Card system ---- */
|
|
.h2-card {
|
|
background: var(--h2-surface-raised);
|
|
border-radius: 12px;
|
|
border: 1px solid var(--h2-border);
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 1px 3px rgba(0,0,0,0.02);
|
|
}
|
|
.h2-card-flush {
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ---- Status rail motif (left-edge accent strip) ---- */
|
|
.h2-rail-accent { border-left: 3px solid var(--h2-accent); }
|
|
.h2-rail-success { border-left: 3px solid var(--h2-success); }
|
|
.h2-rail-warning { border-left: 3px solid var(--h2-warning); }
|
|
.h2-rail-error { border-left: 3px solid var(--h2-error); }
|
|
|
|
/* ---- Section headers ---- */
|
|
.h2-section-header { }
|
|
.h2-section-title {
|
|
font-size: 0.9375rem; /* 15px */
|
|
font-weight: 650;
|
|
letter-spacing: -0.01em;
|
|
color: var(--h2-fg);
|
|
}
|
|
.h2-section-sub {
|
|
margin-top: 2px;
|
|
font-size: 0.75rem;
|
|
color: var(--h2-muted);
|
|
}
|
|
|
|
/* ---- Card headers (icon + title) ---- */
|
|
.h2-card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 1rem 1.25rem;
|
|
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: color-mix(in srgb, var(--h2-accent) 10%, transparent);
|
|
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 1.25rem;
|
|
}
|
|
.h2-list-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.625rem;
|
|
padding: 0.625rem 0;
|
|
border-bottom: 1px solid var(--h2-border);
|
|
}
|
|
.h2-list-item:last-child { border-bottom: 0; padding-bottom: 1rem; }
|
|
.h2-list-item:first-child { padding-top: 0.75rem; }
|
|
|
|
/* ---- 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 0.15s;
|
|
}
|
|
|
|
/* ---- Ops cell borders ---- */
|
|
.h2-cell-border {
|
|
border-right: 1px solid var(--h2-border);
|
|
}
|
|
@media (max-width: 639px) {
|
|
.h2-cell-border:nth-child(2n) { border-right: none; }
|
|
.h2-cell-border { border-bottom: 1px solid var(--h2-border); }
|
|
}
|
|
|
|
/* ---- Ops status background tints ---- */
|
|
.h2-ops-on-track { background: color-mix(in srgb, var(--h2-success) 3%, transparent); }
|
|
.h2-ops-attention { background: color-mix(in srgb, var(--h2-accent) 3%, transparent); }
|
|
.h2-ops-warning { background: color-mix(in srgb, var(--h2-warning) 3%, transparent); }
|
|
.h2-ops-neutral { background: transparent; }
|
|
|
|
/* ---- 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(--h2-accent) !important;
|
|
color: #fff !important;
|
|
border-radius: 10px !important;
|
|
border: none !important;
|
|
box-shadow: 0 1px 3px rgba(13,92,99,0.25), 0 1px 2px rgba(13,92,99,0.15) !important;
|
|
transition: background 0.15s, box-shadow 0.15s !important;
|
|
}
|
|
.h2-btn-primary:hover {
|
|
background: var(--h2-accent-hover) !important;
|
|
box-shadow: 0 2px 6px rgba(13,92,99,0.3), 0 1px 3px rgba(13,92,99,0.2) !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(13, 92, 99, 0.08);
|
|
color: #0d5c63;
|
|
border: 1px solid rgba(13, 92, 99, 0.15);
|
|
}
|
|
|
|
/* ---- Accent text ---- */
|
|
.h2-text-accent { color: #0d5c63; }
|
|
.h2-text-accent-hover { color: #0a4a50; }
|
|
|
|
/* ---- Card hover state ---- */
|
|
.h2-card-hover:hover {
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.04);
|
|
border-color: rgba(13, 92, 99, 0.25);
|
|
}
|
|
</style>
|