WIP jordan
This commit is contained in:
459
app/pages/analysis/index.vue
Normal file
459
app/pages/analysis/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user