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

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 &rarr;
</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 &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 */
--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>