| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- <template>
- <div>
- <DefaultHeaderPage />
- <div class="q-pa-sm">
- <div class="stat-cards-row q-mb-md">
- <DashboardStatCard
- :badge="`${totalAlunos} ativos`"
- :value="String(totalAlunos)"
- :value-color="activeStudentsColor"
- icon="mdi-account-multiple-outline"
- title="Total alunos (contratos ativos)"
- />
- <!-- TODO: usar dados reais de receita (zerado por enquanto). -->
- <DashboardStatCard
- icon="mdi-currency-usd"
- subtitle="0 pagamentos pendentes"
- title="Receita Total"
- value="R$ 0,00"
- />
- <!-- TODO: usar dados reais de ticket médio (zerado por enquanto). -->
- <DashboardStatCard
- icon="mdi-calendar-blank"
- subtitle="Estável"
- title="Ticket Médio"
- value="R$ 0,00"
- />
- <DashboardStatCard
- :value="String(aniversariantes.length)"
- icon="mdi-emoticon-happy-outline"
- subtitle="Fortaleça seus relacionamentos"
- title="Aniversariantes"
- />
- </div>
- <div class="row q-col-gutter-md q-mb-md items-stretch">
- <div class="col-12 col-md-5">
- <DashboardChartCard
- style="height: 100%"
- title="Faturamento Serviço / Materiais"
- >
- <GroupedBarChart
- :datasets="faturamentoChart.datasets"
- :labels="faturamentoChart.labels"
- :tick-formatter="formatCurrencyTick"
- :tooltip-formatter="formatCurrencyTooltip"
- class="full-width full-height"
- label-y="R$"
- />
- </DashboardChartCard>
- </div>
- <div class="col-12 col-md-4">
- <q-card
- bordered
- class="full-height"
- flat
- >
- <q-card-section
- class="row justify-between items-center q-pb-xs"
- >
- <span class="text-subtitle2 text-weight-medium">
- Contratos Ativos
- </span>
- <q-icon
- color="grey-5"
- name="mdi-trending-up"
- />
- </q-card-section>
- <q-separator />
- <q-card-section
- class="flex flex-center q-pt-sm"
- style="height: calc(100% - 57px); position: relative"
- >
- <div style="height: 100%; max-width: 280px; width: 100%">
- <Doughnut
- :data="gaugeData"
- :options="gaugeOptions"
- :plugins="[gaugeNeedlePlugin]"
- />
- </div>
- <div class="gauge-label">
- <div class="text-h5 text-bold">
- {{ activeContracts }}
- </div>
- <div class="text-caption text-grey-6">
- Ativos
- </div>
- </div>
- </q-card-section>
- </q-card>
- </div>
- <div class="col-12 col-md-3">
- <q-card
- bordered
- class="full-height"
- flat
- >
- <q-card-section
- class="row justify-between items-center q-pb-xs"
- >
- <span class="text-subtitle2 text-weight-medium">
- Atalhos rápidos
- </span>
- <q-icon
- color="grey-5"
- name="mdi-apps"
- />
- </q-card-section>
- <q-separator />
- <q-card-section class="q-pt-md column q-gutter-sm">
- <q-btn
- v-if="canAdd"
- class="full-width"
- color="primary"
- label="Criar contrato"
- no-caps
- unelevated
- @click="onCriarContrato"
- />
- <q-btn
- v-if="canEditClasses"
- class="full-width"
- color="primary"
- label="Registrar presença"
- no-caps
- unelevated
- @click="onRegistrarPresenca"
- />
- <div
- v-if="canAddOrders"
- class="full-width cursor-not-allowed"
- >
- <q-btn
- class="full-width"
- color="primary"
- disable
- label="Novo pedido"
- no-caps
- unelevated
- />
- <q-tooltip>
- Esta funcionalidade ainda não foi desenvolvida.
- </q-tooltip>
- </div>
- </q-card-section>
- </q-card>
- </div>
- </div>
- <div class="row q-col-gutter-md items-stretch">
- <div class="col-12 col-md-5">
- <q-card class="card-ring full-height feriados-card" flat>
- <div class="flex justify-between items-center no-wrap q-mb-sm">
- <span class="text-subtitle2 text-weight-regular">
- Feriados do mês
- </span>
- <q-btn
- color="grey-5"
- dense
- flat
- icon="mdi-calendar-star"
- round
- @click="openFeriadosDialog"
- />
- </div>
- <q-btn
- v-if="canAdd"
- class="full-width q-mb-md"
- color="primary"
- label="Nova data"
- no-caps
- unelevated
- @click="openFeriadosDialog"
- />
- <div
- v-if="feriadosLoading"
- class="flex flex-center q-py-md"
- >
- <q-spinner
- color="primary"
- size="24px"
- />
- </div>
- <div
- v-else-if="feriadosMes.length === 0"
- class="text-caption text-grey-5 text-center q-mt-sm"
- >
- Nenhum feriado neste mês.
- </div>
- <div
- v-else
- class="row q-gutter-sm"
- >
- <div
- v-for="feriado in feriadosMes"
- :key="feriado.id"
- class="column items-center cursor-pointer"
- style="min-width: 52px"
- @click="openEditFromDashboard(feriado)"
- >
- <q-badge
- class="text-subtitle1 text-bold q-pa-sm"
- color="deep-orange"
- style="min-width: 40px; justify-content: center"
- >
- {{ feriado.dia }}
- </q-badge>
- <div class="text-caption q-mt-xs text-center">
- {{ feriado.nome }}
- </div>
- </div>
- </div>
- </q-card>
- </div>
- <div class="col-12 col-md-4">
- <DashboardChartCard
- style="height: 100%"
- title="Matrículas por Período"
- >
- <GroupedBarChart
- :bar-percentage="0.85"
- :bar-radius="50"
- :category-percentage="0.6"
- :datasets="matriculasChart.datasets"
- :labels="matriculasChart.labels"
- :max-bar-thickness="44"
- :show-datalabels="true"
- class="full-width full-height"
- />
- </DashboardChartCard>
- </div>
- <div class="col-12 col-md-3">
- <AniversariantesCard
- :people="aniversariantes"
- style="height: 100%"
- />
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup>
- import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js";
- import { computed, onMounted, ref, watch } from "vue";
- import { Doughnut } from "vue-chartjs";
- import { getDashboardSummary } from "src/api/franchisee_dashboard";
- import { getHolidays } from "src/api/holiday";
- import { permissionStore } from "src/stores/permission";
- import { useQuasar } from "quasar";
- import { useRouter } from "vue-router";
- import { userStore } from "src/stores/user";
- import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
- import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
- import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
- import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
- import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
- import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
- import FeriadosDialog from "./components/FeriadosDialog.vue";
- import FeriadosEditDialog from "./components/FeriadosEditDialog.vue";
- ChartJS.register(ArcElement, Tooltip, Legend);
- const $q = useQuasar();
- const router = useRouter();
- const permissions = permissionStore();
- const store = userStore();
- const activeContracts = ref(0);
- const allHolidays = ref([]);
- const aniversariantes = ref([]);
- const feriadosLoading = ref(false);
- const students = ref([]);
- const totalAlunos = ref(0);
- const unitConfig = ref({
- active_students_min: 30,
- active_students_medium: 50,
- active_students_max: 80,
- });
- const activeStudentsColor = computed(() => {
- const count = totalAlunos.value;
- const min = unitConfig.value.active_students_min;
- const med = unitConfig.value.active_students_medium;
- if (count < min) return "negative"; // Vermelho
- if (count >= min && count < med) return "warning"; // Amarelo
- return "positive"; // Verde (>= med)
- });
- const gaugeData = ref({
- datasets: [
- {
- backgroundColor: [
- "#00a550",
- "#4dbb7e",
- "#9ad2ad",
- "#cce156",
- "#fff100",
- "#ffbe00",
- "#ff8c00",
- "#FC3D23",
- "#D01616",
- "#8A0000",
- ],
- borderColor: "transparent",
- data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
- needleValue: 0,
- },
- ],
- });
- const gaugeOptions = ref({
- circumference: 180,
- cutout: "50%",
- maintainAspectRatio: false,
- plugins: {
- datalabels: {
- color: "black",
- font: {
- size: 14,
- weight: "bold",
- },
- formatter: (_value, context) => context.dataIndex,
- },
- legend: {
- display: false,
- },
- tooltip: {
- enabled: false,
- },
- },
- responsive: true,
- rotation: 270,
- });
- const canAdd = computed(() =>
- permissions.getAccess("franchisee_dashboard", "add"),
- );
- const canAddOrders = computed(() =>
- permissions.getAccess("franchisee_orders", "add"),
- );
- const canEditClasses = computed(() =>
- permissions.getAccess("franchisee_classes", "edit"),
- );
- const feriadosMes = computed(() => {
- const now = new Date();
- const month = now.getMonth() + 1;
- const year = now.getFullYear();
- return allHolidays.value
- .filter((holiday) => {
- const date = new Date(`${holiday.holiday_date}T00:00:00`);
- return (
- date.getMonth() + 1 === month &&
- date.getFullYear() === year
- );
- })
- .sort(
- (firstHoliday, secondHoliday) =>
- new Date(firstHoliday.holiday_date) -
- new Date(secondHoliday.holiday_date),
- )
- .map((holiday) => ({
- base_holiday_id: holiday.base_holiday_id ?? null,
- description: holiday.description,
- dia: new Date(`${holiday.holiday_date}T00:00:00`).getDate(),
- holiday_date: holiday.holiday_date,
- id: holiday.id,
- nome: holiday.description,
- type: holiday.type,
- }));
- });
- const matriculasChart = computed(() => {
- const now = new Date();
- const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
- const counts = {};
- for (const student of students.value) {
- if (!student.created_at) continue;
- const date = new Date(student.created_at.replace(" ", "T"));
- if (date < start) continue;
- const key = `${date.getFullYear()}-${date.getMonth()}`;
- counts[key] = (counts[key] ?? 0) + 1;
- }
- const data = [];
- const labels = [];
- for (let index = 0; index < 6; index++) {
- const month = new Date(
- start.getFullYear(),
- start.getMonth() + index,
- 1,
- );
- const key = `${month.getFullYear()}-${month.getMonth()}`;
- labels.push(MONTHS_PT[month.getMonth()]);
- data.push(counts[key] ?? 0);
- }
- return {
- datasets: [
- {
- color: MATRICULA_COLORS,
- data,
- label: "Matrículas",
- },
- ],
- labels,
- };
- });
- // Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda),
- // igual ao Franqueador.
- // TODO: alimentar com dados reais de faturamento de serviço/materiais.
- const faturamentoChart = {
- datasets: [
- {
- color: "#a274f1",
- data: [],
- label: "Serviço",
- },
- {
- color: "#ff9999",
- data: [],
- label: "Materiais",
- },
- ],
- labels: [],
- };
- const gaugeNeedlePlugin = {
- id: "gaugeNeedle",
- afterDatasetsDraw(chart) {
- const { ctx, data } = chart;
- const meta = chart.getDatasetMeta(0).data[0];
- const needleValue = data.datasets[0].needleValue;
- const outerRadius = meta.outerRadius - 20;
- const xCenter = meta.x;
- const yCenter = meta.y;
- const circumference =
- (meta.circumference / Math.PI / data.datasets[0].data[0]) *
- needleValue;
- const angle = Math.PI;
- ctx.save();
- ctx.translate(xCenter, yCenter);
- ctx.rotate(angle * (circumference + 1.5));
- ctx.beginPath();
- ctx.fillStyle = "grey";
- ctx.strokeStyle = "grey";
- ctx.moveTo(-3, 0);
- ctx.lineTo(0, -outerRadius);
- ctx.lineTo(3, 0);
- ctx.stroke();
- ctx.fill();
- ctx.beginPath();
- ctx.arc(0, 0, 6, 0, 2 * Math.PI);
- ctx.fillStyle = "grey";
- ctx.fill();
- ctx.restore();
- },
- };
- const MATRICULA_COLORS = [
- "#3B82F6",
- "#EF4444",
- "#A855F7",
- "#374151",
- "#EAB308",
- "#06B6D4",
- ];
- const MONTHS_PT = [
- "JAN",
- "FEV",
- "MAR",
- "ABR",
- "MAI",
- "JUN",
- "JUL",
- "AGO",
- "SET",
- "OUT",
- "NOV",
- "DEZ",
- ];
- const fetchHolidays = async () => {
- feriadosLoading.value = true;
- try {
- allHolidays.value = await getHolidays();
- } catch {
- $q.notify({
- message: "Erro ao carregar feriados.",
- type: "negative",
- });
- } finally {
- feriadosLoading.value = false;
- }
- };
- const fetchSummary = async () => {
- try {
- const summary = await getDashboardSummary();
- activeContracts.value = summary.contracts.active;
- aniversariantes.value = summary.birthdays;
- students.value = summary.enrollments;
- totalAlunos.value = summary.students_count;
- if (summary.unit_config) {
- unitConfig.value = summary.unit_config;
- }
- gaugeData.value.datasets[0].needleValue = Math.min(
- activeContracts.value,
- 10,
- );
- } catch {
- // silencioso
- }
- };
- const formatCurrencyTick = (value) => {
- if (value >= 1000) {
- return `R$ ${(value / 1000).toFixed(0)}k`;
- }
- return `R$ ${value}`;
- };
- const formatCurrencyTooltip = (context) => {
- const value = context.parsed.y;
- return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", {
- minimumFractionDigits: 2,
- })}`;
- };
- const onCriarContrato = () => {
- $q.dialog({
- component: AddEditContractDialog,
- componentProps: {
- selectStudent: true,
- },
- }).onOk(fetchSummary);
- };
- const onRegistrarPresenca = () => {
- router.push({ name: "ClassPage" });
- };
- const openEditFromDashboard = (feriado) => {
- $q.dialog({
- component: FeriadosEditDialog,
- componentProps: {
- holiday: feriado,
- },
- }).onOk(({ action, holiday: updated, id }) => {
- if (action === "update") {
- const index = allHolidays.value.findIndex(
- (holiday) => holiday.id === updated.id,
- );
- if (index !== -1) {
- allHolidays.value[index] = {
- ...allHolidays.value[index],
- ...updated,
- };
- }
- return;
- }
- if (action === "delete") {
- allHolidays.value = allHolidays.value.filter(
- (holiday) => holiday.id !== id,
- );
- }
- });
- };
- const openFeriadosDialog = () => {
- $q.dialog({
- component: FeriadosDialog,
- }).onOk(fetchHolidays);
- };
- watch(
- () => store.selectedUnit?.id,
- (newId, oldId) => {
- if (!newId || newId === oldId) return;
- fetchSummary();
- fetchHolidays();
- },
- );
- onMounted(() => {
- fetchHolidays();
- fetchSummary();
- });
- </script>
- <style scoped>
- .stat-cards-row {
- display: flex;
- flex-wrap: nowrap;
- gap: 16px;
- }
- .stat-cards-row > * {
- flex: 1 1 0;
- min-width: 0;
- }
- .gauge-label {
- position: absolute;
- bottom: 28%;
- left: 50%;
- pointer-events: none;
- text-align: center;
- transform: translateX(-50%);
- }
- .feriados-card {
- display: flex;
- flex-direction: column;
- padding: 20px 24px;
- border-radius: 12px;
- }
- @media (max-width: 599px) {
- .stat-cards-row {
- flex-wrap: wrap;
- }
- .stat-cards-row > * {
- flex: 1 1 calc(50% - 8px);
- }
- }
- </style>
|