WIP jordan

This commit is contained in:
Jordan Weingarten
2026-04-16 11:11:44 -05:00
parent ff2d7b18b5
commit 67482f6629
163 changed files with 50627 additions and 728 deletions

View File

@@ -0,0 +1,459 @@
<script setup lang="ts">
import {
ANALYTICS_DOMAIN_LABELS,
ANALYTICS_METRICS,
type AnalyticsDomainId,
type AnalyticsChartType,
type AnalyticsTimePoint,
} from '~/data/mock-analytics'
usePageTitle('Business Analytics')
const {
state, allMetrics, kpiSummaries, domainMetrics,
chartBuilderMetricObj, chartBuilderData, chartBuilderSvgModel,
buildSvgModel, sparklinePath, sparklineArea,
} = useAnalytics()
// ── Domain tabs ──
const domains: { id: AnalyticsDomainId; label: string }[] = [
{ id: 'production', label: 'Producción' },
{ id: 'claims', label: 'Siniestros' },
{ id: 'pipeline', label: 'Pipeline' },
{ id: 'service', label: 'Servicio' },
]
// ── Per-card chart type overrides ──
const cardChartTypes = ref<Record<string, AnalyticsChartType>>({})
function getCardChartType(metricId: string, defaultType: AnalyticsChartType): AnalyticsChartType {
return cardChartTypes.value[metricId] ?? defaultType
}
function setCardChartType(metricId: string, type: AnalyticsChartType) {
cardChartTypes.value[metricId] = type
}
// ── Chart builder grouped options ──
const chartBuilderGroups = computed(() => {
const domainOrder: AnalyticsDomainId[] = ['production', 'claims', 'pipeline', 'service']
return domainOrder.map(d => ({
label: ANALYTICS_DOMAIN_LABELS[d],
metrics: allMetrics.filter(m => m.domain === d && m.data12m.some(p => p.m)),
}))
})
// ── Filter valid data points (skip empties) for chart rendering ──
function validData(data: AnalyticsTimePoint[]): AnalyticsTimePoint[] {
return data.filter(d => d.m)
}
// ── Change badge class ──
function changeToneClass(tone: string): string {
if (tone === 'positive') return 'an-change-positive'
if (tone === 'negative') return 'an-change-negative'
return 'an-change-neutral'
}
</script>
<template>
<div class="an-page">
<!-- Header -->
<div>
<h1 class="mt-1 text-2xl font-semibold tracking-tight text-[var(--text-primary)]">Business Analytics</h1>
<p class="mt-2 text-[14px] leading-relaxed text-[var(--text-muted)]">
Consolidated view production, claims, pipeline, and service KPIs with interactive charts.
</p>
</div>
<!-- KPI STRIP -->
<div class="an-kpi-strip">
<div v-for="kpi in kpiSummaries" :key="kpi.id" class="an-kpi-card">
<div class="an-kpi-top">
<p class="an-kpi-label">{{ kpi.label }}</p>
<span :class="['an-change-badge', changeToneClass(kpi.changeTone)]">{{ kpi.change }}</span>
</div>
<p class="an-kpi-value">{{ kpi.value }}</p>
<p class="an-kpi-hint">{{ kpi.hint }}</p>
<svg class="an-kpi-spark" viewBox="0 0 112 32" preserveAspectRatio="none">
<path :d="sparklineArea(kpi.sparkline)" fill="rgba(1,105,111,0.06)" />
<path :d="sparklinePath(kpi.sparkline)" fill="none" stroke="#01696f" stroke-width="1.5" />
</svg>
</div>
</div>
<!-- DOMAIN TABS -->
<div class="an-domain-tabs">
<button
v-for="d in domains"
:key="d.id"
type="button"
class="an-domain-tab"
:class="state.activeDomain === d.id ? 'an-tab-active' : 'an-tab-inactive'"
@click="state.activeDomain = d.id"
>
{{ d.label }}
</button>
</div>
<!-- CHART BUILDER -->
<div class="an-builder">
<div class="an-builder-header">
<p class="an-builder-title">
<UIcon name="i-heroicons-wrench-screwdriver" class="w-4 h-4" />
Chart Builder
</p>
<p class="text-[12px] text-[var(--text-muted)]">Pick any metric, chart type, and time range.</p>
</div>
<div class="an-builder-controls">
<!-- Grouped metric selector -->
<select v-model="state.chartBuilderMetric" class="an-builder-select an-builder-select-wide">
<optgroup v-for="group in chartBuilderGroups" :key="group.label" :label="group.label">
<option v-for="m in group.metrics" :key="m.id" :value="m.id">{{ m.label }}</option>
</optgroup>
</select>
<!-- Chart type toggle -->
<div class="an-builder-toggle">
<button
v-for="ct in (['area', 'line', 'bar'] as AnalyticsChartType[])"
:key="ct"
type="button"
class="an-bt-btn"
:class="state.chartBuilderType === ct ? 'an-bt-on' : 'an-bt-off'"
@click="state.chartBuilderType = ct"
>
{{ ct.charAt(0).toUpperCase() + ct.slice(1) }}
</button>
</div>
<!-- Time range -->
<div class="an-builder-toggle">
<button
v-for="r in (['3m', '6m', '12m'] as const)"
:key="r"
type="button"
class="an-bt-btn"
:class="state.chartBuilderRange === r ? 'an-bt-on' : 'an-bt-off'"
@click="state.chartBuilderRange = r"
>
{{ r.toUpperCase() }}
</button>
</div>
</div>
<!-- Builder chart info -->
<div class="an-builder-info">
<p class="text-[18px] font-bold text-[var(--text-primary)]">
{{ chartBuilderData[chartBuilderData.length - 1]?.display }}
</p>
<span :class="['an-change-badge', changeToneClass(chartBuilderMetricObj.changeTone)]">{{ chartBuilderMetricObj.change }}</span>
<span class="text-[12px] text-[var(--text-muted)] ml-2">{{ chartBuilderMetricObj.label }}</span>
</div>
<!-- Builder SVG -->
<svg class="an-builder-svg" :viewBox="`0 0 ${chartBuilderSvgModel.viewW} ${chartBuilderSvgModel.viewH}`" preserveAspectRatio="none">
<line
v-for="(gy, gi) in chartBuilderSvgModel.gridYs"
:key="gi"
:x1="chartBuilderSvgModel.padX"
:y1="gy"
:x2="chartBuilderSvgModel.padX + chartBuilderSvgModel.innerW"
:y2="gy"
stroke="rgba(0,0,0,0.04)"
stroke-width="1"
/>
<path
v-if="state.chartBuilderType === 'area'"
:d="chartBuilderSvgModel.areaD"
fill="rgba(1,105,111,0.08)"
/>
<path
v-if="state.chartBuilderType !== 'bar'"
:d="chartBuilderSvgModel.lineD"
fill="none"
stroke="#01696f"
stroke-width="2"
/>
<circle
v-if="state.chartBuilderType !== 'bar'"
v-for="(pt, pi) in chartBuilderSvgModel.points"
:key="pi"
:cx="pt.x"
:cy="pt.y"
r="4"
fill="#fff"
stroke="#01696f"
stroke-width="2"
/>
<rect
v-if="state.chartBuilderType === 'bar'"
v-for="(bar, bi) in chartBuilderSvgModel.bars"
:key="bi"
:x="bar.x"
:y="bar.y"
:width="bar.w"
:height="bar.h"
rx="4"
fill="#01696f"
opacity="0.7"
/>
</svg>
<!-- Builder x-axis -->
<div class="an-chart-xaxis" style="padding: 0 8px;">
<span v-for="(d, di) in chartBuilderData" :key="di" class="an-xaxis-label">{{ d.m }}</span>
</div>
</div>
<!-- CHART CARDS GRID -->
<div class="an-chart-grid">
<div
v-for="metric in domainMetrics"
:key="metric.id"
class="an-chart-card"
>
<!-- Card header -->
<div class="an-chart-header">
<div class="flex-1 min-w-0">
<p class="an-chart-title">{{ metric.label }}</p>
<span v-if="metric.change" :class="['an-change-badge', changeToneClass(metric.changeTone)]">{{ metric.change }}</span>
</div>
<div class="an-chart-type-toggle">
<button
v-for="ct in (['area', 'line', 'bar'] as AnalyticsChartType[])"
:key="ct"
type="button"
class="an-ct-btn"
:class="getCardChartType(metric.id, metric.defaultChartType) === ct ? 'an-ct-on' : 'an-ct-off'"
@click="setCardChartType(metric.id, ct)"
>
{{ ct === 'area' ? '▤' : ct === 'line' ? '⌇' : '▥' }}
</button>
</div>
</div>
<!-- SVG chart -->
<svg class="an-chart-svg" :viewBox="`0 0 ${buildSvgModel(validData(metric.data12m)).viewW} ${buildSvgModel(validData(metric.data12m)).viewH}`" preserveAspectRatio="none">
<!-- Grid lines -->
<line
v-for="(gy, gi) in buildSvgModel(validData(metric.data12m)).gridYs"
:key="gi"
:x1="buildSvgModel(validData(metric.data12m)).padX"
:y1="gy"
:x2="buildSvgModel(validData(metric.data12m)).padX + buildSvgModel(validData(metric.data12m)).innerW"
:y2="gy"
stroke="rgba(0,0,0,0.04)"
stroke-width="1"
/>
<!-- Area -->
<path
v-if="getCardChartType(metric.id, metric.defaultChartType) === 'area'"
:d="buildSvgModel(validData(metric.data12m)).areaD"
fill="rgba(1,105,111,0.08)"
/>
<!-- Line -->
<path
v-if="getCardChartType(metric.id, metric.defaultChartType) !== 'bar'"
:d="buildSvgModel(validData(metric.data12m)).lineD"
fill="none"
stroke="#01696f"
stroke-width="2"
/>
<!-- Points -->
<circle
v-if="getCardChartType(metric.id, metric.defaultChartType) !== 'bar'"
v-for="(pt, pi) in buildSvgModel(validData(metric.data12m)).points"
:key="pi"
:cx="pt.x"
:cy="pt.y"
r="3"
fill="#fff"
stroke="#01696f"
stroke-width="1.5"
/>
<!-- Bars -->
<rect
v-if="getCardChartType(metric.id, metric.defaultChartType) === 'bar'"
v-for="(bar, bi) in buildSvgModel(validData(metric.data12m)).bars"
:key="bi"
:x="bar.x"
:y="bar.y"
:width="bar.w"
:height="bar.h"
rx="3"
fill="#01696f"
opacity="0.7"
/>
</svg>
<!-- X-axis labels -->
<div class="an-chart-xaxis">
<span v-for="(d, di) in validData(metric.data12m)" :key="di" class="an-xaxis-label">{{ d.m }}</span>
</div>
<!-- Latest value -->
<div class="an-chart-latest">
<span class="text-[13px] font-semibold text-[var(--text-primary)]">{{ validData(metric.data12m)[validData(metric.data12m).length - 1]?.display }}</span>
<span class="text-[11px] text-[var(--text-muted)]">latest</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.an-page {
max-width: 72rem;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
padding-bottom: 3rem;
}
/* ══════════ KPI STRIP ══════════ */
.an-kpi-strip {
display: grid; grid-template-columns: repeat(6, 1fr); gap: 12px;
}
.an-kpi-card {
padding: 14px 16px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
position: relative; overflow: hidden;
}
.an-kpi-top { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
.an-kpi-label {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.04em; color: #8a8a86;
}
.an-kpi-value {
margin-top: 4px; font-size: 20px; font-weight: 700;
color: var(--text-primary); font-variant-numeric: tabular-nums;
}
.an-kpi-hint { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
.an-kpi-spark {
position: absolute; bottom: 0; left: 0; right: 0;
width: 100%; height: 32px; opacity: 0.6;
}
@media (max-width: 900px) { .an-kpi-strip { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 500px) { .an-kpi-strip { grid-template-columns: repeat(2, 1fr); } }
/* ══════════ CHANGE BADGES ══════════ */
.an-change-badge {
display: inline-flex; padding: 1px 6px; border-radius: 6px;
font-size: 10px; font-weight: 700; white-space: nowrap;
}
.an-change-positive { background: rgba(5,150,105,0.08); color: #059669; }
.an-change-negative { background: rgba(193,56,56,0.08); color: #c13838; }
.an-change-neutral { background: rgba(0,0,0,0.04); color: #8a8a86; }
/* ══════════ DOMAIN TABS ══════════ */
.an-domain-tabs {
display: inline-flex; gap: 2px; padding: 3px;
border-radius: 10px; background: rgba(0,0,0,0.04);
width: fit-content;
}
.an-domain-tab {
padding: 8px 18px; border-radius: 8px;
font-size: 13px; font-weight: 500; border: none;
cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.an-tab-active { background: #fff; color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.an-tab-inactive { background: transparent; color: var(--text-muted); }
.an-tab-inactive:hover { color: var(--text-primary); }
/* ══════════ CHART GRID ══════════ */
.an-chart-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;
}
@media (max-width: 700px) { .an-chart-grid { grid-template-columns: 1fr; } }
.an-chart-card {
padding: 16px; border-radius: 12px;
border: 1px solid rgba(0,0,0,0.06); background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
/* ── Chart header ── */
.an-chart-header {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 10px;
}
.an-chart-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.an-chart-type-toggle {
display: inline-flex; gap: 1px; padding: 2px;
border-radius: 6px; background: rgba(0,0,0,0.03);
}
.an-ct-btn {
width: 24px; height: 22px; border-radius: 4px;
font-size: 10px; border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 100ms ease;
}
.an-ct-on { background: #01696f; color: #fff; }
.an-ct-off { background: transparent; color: #8a8a86; }
.an-ct-off:hover { color: var(--text-primary); }
/* ── SVG chart ── */
.an-chart-svg { width: 100%; height: 120px; }
/* ── X-axis labels ── */
.an-chart-xaxis {
display: flex; justify-content: space-between;
padding: 4px 8px 0;
}
.an-xaxis-label {
font-size: 9px; font-weight: 600; color: #8a8a86;
text-transform: uppercase;
}
/* ── Latest value ── */
.an-chart-latest {
display: flex; align-items: center; gap: 6px;
margin-top: 8px; padding-top: 8px;
border-top: 1px solid rgba(0,0,0,0.04);
}
/* ══════════ CHART BUILDER ══════════ */
.an-builder {
padding: 20px; border-radius: 12px;
border: 1px solid rgba(1,105,111,0.12); background: rgba(1,105,111,0.01);
}
.an-builder-header { margin-bottom: 16px; }
.an-builder-title {
display: flex; align-items: center; gap: 6px;
font-size: 15px; font-weight: 600; color: var(--text-primary);
}
.an-builder-controls {
display: flex; gap: 10px; flex-wrap: wrap; align-items: center;
margin-bottom: 14px;
}
.an-builder-select {
padding: 6px 12px; border-radius: 8px; font-size: 12px; font-weight: 500;
border: 1px solid rgba(0,0,0,0.08); background: #fff; color: var(--text-primary);
cursor: pointer;
}
.an-builder-select-wide { min-width: 200px; }
.an-builder-select:focus { outline: none; border-color: #01696f; }
.an-builder-toggle {
display: inline-flex; gap: 1px; padding: 2px;
border-radius: 8px; background: rgba(0,0,0,0.04);
}
.an-bt-btn {
padding: 5px 12px; border-radius: 6px; font-size: 11px; font-weight: 600;
border: none; cursor: pointer; transition: all 150ms ease; white-space: nowrap;
}
.an-bt-on { background: #01696f; color: #fff; }
.an-bt-off { background: transparent; color: #8a8a86; }
.an-bt-off:hover { color: var(--text-primary); }
.an-builder-info {
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
}
.an-builder-svg { width: 100%; height: 180px; }
</style>