Pré-requisitos: npm install chart.js vue-chartjs axios pinia
Todos os exemplos usam Composition API, Pinia para state e vue-chartjs para gráficos.
// stores/kpiStore.js — Pinia store para KPIs
import { defineStore } from 'pinia'
import axios from '@/plugins/axios' // instância com baseURL e token Bearer
export const useKpiStore = defineStore('kpi', {
state: () => ({
summary: null,
revenue: [],
readings: null,
technicians: [],
loading: false,
error: null,
period: new Date().toISOString().slice(0, 7), // "2026-03"
operatorId: localStorage.getItem('operator_id'),
}),
actions: {
async fetchSummary() {
this.loading = true
try {
const { data } = await axios.get('/kpi/summary', {
params: { period: this.period },
headers: { 'X-Operator-Id': this.operatorId },
})
this.summary = data
} catch (e) {
this.error = e.response?.data?.message ?? e.message
} finally {
this.loading = false
}
},
async fetchRevenue(from, to, groupBy = 'month') {
const { data } = await axios.get('/kpi/revenue', {
params: { from, to, group_by: groupBy },
headers: { 'X-Operator-Id': this.operatorId },
})
this.revenue = data.data
},
async fetchReadings() {
const { data } = await axios.get('/kpi/readings', {
params: { period: this.period },
headers: { 'X-Operator-Id': this.operatorId },
})
this.readings = data
},
async fetchTechnicians() {
const { data } = await axios.get('/kpi/technicians', {
params: { period: this.period },
headers: { 'X-Operator-Id': this.operatorId },
})
this.technicians = data
},
},
getters: {
collectionRateColor: (state) => {
const rate = state.summary?.collection_rate ?? 0
if (rate >= 90) return 'text-green-600'
if (rate >= 70) return 'text-yellow-600'
return 'text-red-600'
},
},
})
<!-- components/KpiDashboard.vue -->
<template>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<KpiCard
v-for="card in cards"
:key="card.label"
:label="card.label"
:value="card.value"
:sub="card.sub"
:color="card.color"
:loading="kpi.loading"
/>
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import { useKpiStore } from '@/stores/kpiStore'
import KpiCard from './KpiCard.vue'
const kpi = useKpiStore()
onMounted(() => kpi.fetchSummary())
const fmt = (n) => new Intl.NumberFormat('pt-MZ').format(n ?? 0)
const fmtMzn = (n) => `${fmt(n)} MZN`
const cards = computed(() => [
{
label: 'Total Consumidores',
value: fmt(kpi.summary?.total_consumers),
sub: `${fmt(kpi.summary?.active_contracts)} contratos activos`,
color: 'blue',
},
{
label: 'Receita do Mês',
value: fmtMzn(kpi.summary?.revenue_mtd),
sub: `Taxa de cobrança: ${kpi.summary?.collection_rate?.toFixed(1)}%`,
color: kpi.summary?.collection_rate >= 90 ? 'green' : 'orange',
},
{
label: 'Leituras Realizadas',
value: fmt(kpi.summary?.readings_this_month),
sub: `Cobertura: ${kpi.summary?.reading_coverage?.toFixed(1)}%`,
color: 'indigo',
},
{
label: 'Faturas em Atraso',
value: fmt(kpi.summary?.overdue_invoices),
sub: fmtMzn(kpi.summary?.overdue_amount),
color: kpi.summary?.overdue_invoices > 0 ? 'red' : 'green',
},
])
</script>
<!-- components/RevenueChart.vue -->
<template>
<div class="bg-white rounded-xl shadow p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Receita Mensal</h3>
<select v-model="year" @change="load" class="border rounded px-2 py-1 text-sm">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<Bar :data="chartData" :options="chartOptions" style="height:300px" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Bar } from 'vue-chartjs'
import { Chart as ChartJS, BarElement, CategoryScale, LinearScale, Tooltip, Legend } from 'chart.js'
import { useKpiStore } from '@/stores/kpiStore'
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)
const kpi = useKpiStore()
const year = ref(new Date().getFullYear())
const years = [year.value - 1, year.value]
const load = () => kpi.fetchRevenue(`${year.value}-01-01`, `${year.value}-12-31`, 'month')
onMounted(load)
const MONTHS = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']
const chartData = computed(() => ({
labels: MONTHS,
datasets: [{
label: 'Receita (MZN)',
data: MONTHS.map((_, i) => {
const m = String(i + 1).padStart(2, '0')
return kpi.revenue.find(r => r.period === `${year.value}-${m}`)?.amount ?? 0
}),
backgroundColor: 'rgba(59, 130, 246, 0.7)',
borderColor: 'rgba(59, 130, 246, 1)',
borderWidth: 1,
borderRadius: 6,
}],
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => ` ${new Intl.NumberFormat('pt-MZ').format(ctx.raw)} MZN`,
},
},
},
scales: {
y: {
ticks: {
callback: (v) => `${(v / 1000).toFixed(0)}k`,
},
},
},
}
</script>
<!-- components/TechnicianRanking.vue -->
<template>
<div class="bg-white rounded-xl shadow p-6">
<h3 class="font-bold text-lg mb-4">Ranking de Técnicos — {{ kpi.period }}</h3>
<div v-if="kpi.loading" class="animate-pulse space-y-2">
<div v-for="i in 5" :key="i" class="h-10 bg-gray-100 rounded"></div>
</div>
<table v-else class="w-full text-sm">
<thead>
<tr class="text-left text-gray-500 border-b">
<th class="pb-2">#</th>
<th class="pb-2">Técnico</th>
<th class="pb-2 text-right">Leituras</th>
<th class="pb-2 text-right">Score</th>
<th class="pb-2">Progresso</th>
</tr>
</thead>
<tbody>
<tr
v-for="tech in kpi.technicians"
:key="tech.rank"
class="border-b last:border-none hover:bg-gray-50"
>
<td class="py-3 pr-3">
<span
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
:class="tech.rank === 1 ? 'bg-yellow-100 text-yellow-800' : 'bg-gray-100 text-gray-600'"
>{{ tech.rank }}</span>
</td>
<td class="py-3 font-medium">{{ tech.name }}</td>
<td class="py-3 text-right">{{ tech.readings_count }}</td>
<td class="py-3 text-right font-bold" :class="scoreColor(tech.score)">
{{ tech.score?.toFixed(1) }}
</td>
<td class="py-3 pl-4">
<div class="w-full bg-gray-100 rounded-full h-2">
<div
class="h-2 rounded-full transition-all"
:class="tech.score >= 80 ? 'bg-green-500' : tech.score >= 60 ? 'bg-yellow-500' : 'bg-red-400'"
:style="{ width: tech.score + '%' }"
></div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { useKpiStore } from '@/stores/kpiStore'
const kpi = useKpiStore()
onMounted(() => kpi.fetchTechnicians())
const scoreColor = (score) => {
if (score >= 80) return 'text-green-600'
if (score >= 60) return 'text-yellow-600'
return 'text-red-600'
}
</script>
<!-- components/ReadingsCoverageChart.vue -->
<template>
<div class="bg-white rounded-xl shadow p-6">
<h3 class="font-bold text-lg mb-1">Leituras — {{ kpi.period }}</h3>
<p class="text-sm text-gray-500 mb-4">Cobertura: {{ kpi.readings?.coverage_rate?.toFixed(1) }}%</p>
<Doughnut :data="chartData" :options="chartOptions" style="max-height:250px" />
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { useKpiStore } from '@/stores/kpiStore'
ChartJS.register(ArcElement, Tooltip, Legend)
const kpi = useKpiStore()
onMounted(() => kpi.fetchReadings())
const chartData = computed(() => ({
labels: ['Reais', 'Estimadas'],
datasets: [{
data: [kpi.readings?.real ?? 0, kpi.readings?.estimated ?? 0],
backgroundColor: ['rgba(59,130,246,0.8)', 'rgba(245,158,11,0.8)'],
borderColor: ['#3b82f6', '#f59e0b'],
borderWidth: 1,
}],
}))
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'bottom' },
},
cutout: '65%',
}
</script>