← Documentação |

Exemplos de KPIs — Vue.js 3 + Chart.js

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>