DashboardPage.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679
  1. <template>
  2. <div>
  3. <DefaultHeaderPage />
  4. <div class="q-pa-sm">
  5. <div class="stat-cards-row q-mb-md">
  6. <DashboardStatCard
  7. :badges="studentsStatusBadges"
  8. :value="String(totalAlunos)"
  9. :value-color="activeStudentsColor"
  10. clickable
  11. icon="mdi-account-multiple-outline"
  12. title="Total alunos"
  13. @click="openStudentsDialog"
  14. />
  15. <!-- TODO: usar dados reais de receita (zerado por enquanto). -->
  16. <DashboardStatCard
  17. icon="mdi-currency-usd"
  18. subtitle="0 pagamentos pendentes"
  19. title="Receita Total"
  20. value="R$ 0,00"
  21. />
  22. <!-- TODO: usar dados reais de ticket médio (zerado por enquanto). -->
  23. <DashboardStatCard
  24. icon="mdi-calendar-blank"
  25. subtitle="Estável"
  26. title="Ticket Médio"
  27. value="R$ 0,00"
  28. />
  29. <DashboardStatCard
  30. :value="String(aniversariantes.length)"
  31. icon="mdi-emoticon-happy-outline"
  32. subtitle="Fortaleça seus relacionamentos"
  33. title="Aniversariantes"
  34. />
  35. </div>
  36. <div class="row q-col-gutter-md q-mb-md items-stretch">
  37. <div class="col-12 col-md-5">
  38. <DashboardChartCard
  39. style="height: 100%"
  40. title="Faturamento Serviço / Materiais"
  41. >
  42. <GroupedBarChart
  43. :datasets="faturamentoChart.datasets"
  44. :labels="faturamentoChart.labels"
  45. :tick-formatter="formatCurrencyTick"
  46. :tooltip-formatter="formatCurrencyTooltip"
  47. class="full-width full-height"
  48. label-y="R$"
  49. />
  50. </DashboardChartCard>
  51. </div>
  52. <div class="col-12 col-md-4">
  53. <q-card
  54. bordered
  55. class="full-height"
  56. flat
  57. >
  58. <q-card-section
  59. class="row justify-between items-center q-pb-xs"
  60. >
  61. <span class="text-subtitle2 text-weight-medium">
  62. Contratos Ativos
  63. </span>
  64. <q-icon
  65. color="grey-5"
  66. name="mdi-trending-up"
  67. />
  68. </q-card-section>
  69. <q-separator />
  70. <q-card-section
  71. class="flex flex-center q-pt-sm"
  72. style="height: calc(100% - 57px); position: relative"
  73. >
  74. <div style="height: 100%; max-width: 280px; width: 100%">
  75. <Doughnut
  76. :data="gaugeData"
  77. :options="gaugeOptions"
  78. :plugins="[gaugeNeedlePlugin]"
  79. />
  80. </div>
  81. <div class="gauge-label">
  82. <div class="text-h5 text-bold">
  83. {{ activeContracts }}
  84. </div>
  85. <div class="text-caption text-grey-6">
  86. Ativos
  87. </div>
  88. </div>
  89. </q-card-section>
  90. </q-card>
  91. </div>
  92. <div class="col-12 col-md-3">
  93. <q-card
  94. bordered
  95. class="full-height"
  96. flat
  97. >
  98. <q-card-section
  99. class="row justify-between items-center q-pb-xs"
  100. >
  101. <span class="text-subtitle2 text-weight-medium">
  102. Atalhos rápidos
  103. </span>
  104. <q-icon
  105. color="grey-5"
  106. name="mdi-apps"
  107. />
  108. </q-card-section>
  109. <q-separator />
  110. <q-card-section class="q-pt-md column q-gutter-sm">
  111. <q-btn
  112. v-if="canAdd"
  113. class="full-width"
  114. color="primary"
  115. label="Criar contrato"
  116. no-caps
  117. unelevated
  118. @click="onCriarContrato"
  119. />
  120. <q-btn
  121. v-if="canEditClasses"
  122. class="full-width"
  123. color="primary"
  124. label="Registrar presença"
  125. no-caps
  126. unelevated
  127. @click="onRegistrarPresenca"
  128. />
  129. </q-card-section>
  130. </q-card>
  131. </div>
  132. </div>
  133. <div class="row q-col-gutter-md items-stretch">
  134. <div class="col-12 col-md-5">
  135. <q-card class="card-ring full-height feriados-card" flat>
  136. <div class="flex justify-between items-center no-wrap q-mb-sm">
  137. <span class="text-subtitle2 text-weight-regular">
  138. Feriados do mês
  139. </span>
  140. <q-btn
  141. color="grey-5"
  142. dense
  143. flat
  144. icon="mdi-calendar-star"
  145. round
  146. @click="openFeriadosDialog"
  147. />
  148. </div>
  149. <q-btn
  150. v-if="canAdd"
  151. class="full-width q-mb-md"
  152. color="primary"
  153. label="Nova data"
  154. no-caps
  155. unelevated
  156. @click="openFeriadosDialog"
  157. />
  158. <div
  159. v-if="feriadosLoading"
  160. class="flex flex-center q-py-md"
  161. >
  162. <q-spinner
  163. color="primary"
  164. size="24px"
  165. />
  166. </div>
  167. <div
  168. v-else-if="feriadosMes.length === 0"
  169. class="text-caption text-grey-5 text-center q-mt-sm"
  170. >
  171. Nenhum feriado neste mês.
  172. </div>
  173. <div
  174. v-else
  175. class="row q-gutter-sm"
  176. >
  177. <div
  178. v-for="feriado in feriadosMes"
  179. :key="feriado.id"
  180. class="column items-center cursor-pointer"
  181. style="min-width: 52px"
  182. @click="openEditFromDashboard(feriado)"
  183. >
  184. <q-badge
  185. class="text-subtitle1 text-bold q-pa-sm"
  186. color="deep-orange"
  187. style="min-width: 40px; justify-content: center"
  188. >
  189. {{ feriado.dia }}
  190. </q-badge>
  191. <div class="text-caption q-mt-xs text-center">
  192. {{ feriado.nome }}
  193. </div>
  194. </div>
  195. </div>
  196. </q-card>
  197. </div>
  198. <div class="col-12 col-md-4">
  199. <DashboardChartCard
  200. style="height: 100%"
  201. title="Matrículas por Período"
  202. >
  203. <GroupedBarChart
  204. :bar-percentage="0.85"
  205. :bar-radius="50"
  206. :category-percentage="0.6"
  207. :datasets="matriculasChart.datasets"
  208. :labels="matriculasChart.labels"
  209. :max-bar-thickness="44"
  210. :show-datalabels="true"
  211. class="full-width full-height"
  212. />
  213. </DashboardChartCard>
  214. </div>
  215. <div class="col-12 col-md-3">
  216. <AniversariantesCard
  217. :people="aniversariantes"
  218. style="height: 100%"
  219. />
  220. </div>
  221. </div>
  222. </div>
  223. </div>
  224. </template>
  225. <script setup>
  226. import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js";
  227. import { computed, onMounted, ref, watch } from "vue";
  228. import { Doughnut } from "vue-chartjs";
  229. import { getDashboardSummary } from "src/api/franchisee_dashboard";
  230. import { getHolidays } from "src/api/holiday";
  231. import { permissionStore } from "src/stores/permission";
  232. import { useQuasar } from "quasar";
  233. import { useRouter } from "vue-router";
  234. import { userStore } from "src/stores/user";
  235. import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
  236. import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
  237. import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
  238. import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
  239. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  240. import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
  241. import FeriadosDialog from "./components/FeriadosDialog.vue";
  242. import FeriadosEditDialog from "./components/FeriadosEditDialog.vue";
  243. import StudentsStatusDialog from "./components/StudentsStatusDialog.vue";
  244. ChartJS.register(ArcElement, Tooltip, Legend);
  245. const $q = useQuasar();
  246. const router = useRouter();
  247. const permissions = permissionStore();
  248. const store = userStore();
  249. const activeContracts = ref(0);
  250. const allHolidays = ref([]);
  251. const aniversariantes = ref([]);
  252. const feriadosLoading = ref(false);
  253. const students = ref([]);
  254. const studentsByStatus = ref({ active: 0, ex_student: 0, lead: 0, locked: 0 });
  255. const totalAlunos = ref(0);
  256. const unitConfig = ref({
  257. active_students_min: 30,
  258. active_students_max: 80,
  259. });
  260. const activeStudentsColor = computed(() => {
  261. const count = totalAlunos.value;
  262. const min = unitConfig.value.active_students_min;
  263. const max = unitConfig.value.active_students_max;
  264. if (count < min) return "negative"; // Vermelho
  265. if (count < max) return "warning"; // Amarelo
  266. return "positive"; // Verde (>= max)
  267. });
  268. const studentsStatusBadges = computed(() => [
  269. { color: "positive", label: `${studentsByStatus.value.active} Ativo` },
  270. { color: "orange", label: `${studentsByStatus.value.locked} Trancado` },
  271. { color: "grey-7", label: `${studentsByStatus.value.ex_student} Ex-aluno` },
  272. { color: "blue", label: `${studentsByStatus.value.lead} Lead` },
  273. ]);
  274. // Faixas do medidor "Contratos Ativos" — mesma meta configurável de
  275. // alunos ativos (Dashboard > Metas do Dashboard): vermelho até o mínimo,
  276. // amarelo entre mínimo e máximo, verde a partir do máximo.
  277. const gaugeScaleMax = computed(() => {
  278. const max = Math.max(unitConfig.value.active_students_max, 1);
  279. return max * 1.2;
  280. });
  281. const gaugeData = computed(() => {
  282. const min = Math.max(unitConfig.value.active_students_min, 0);
  283. const max = Math.max(unitConfig.value.active_students_max, min + 1);
  284. const scaleMax = Math.max(gaugeScaleMax.value, max + 1);
  285. return {
  286. datasets: [
  287. {
  288. backgroundColor: ["#D01616", "#ffbe00", "#00a550"],
  289. borderColor: "transparent",
  290. data: [min, max - min, scaleMax - max],
  291. needleValue: Math.min(activeContracts.value, scaleMax),
  292. },
  293. ],
  294. };
  295. });
  296. const gaugeOptions = ref({
  297. circumference: 180,
  298. cutout: "50%",
  299. maintainAspectRatio: false,
  300. plugins: {
  301. datalabels: {
  302. display: false,
  303. },
  304. legend: {
  305. display: false,
  306. },
  307. tooltip: {
  308. enabled: false,
  309. },
  310. },
  311. responsive: true,
  312. rotation: 270,
  313. });
  314. const canAdd = computed(() =>
  315. permissions.getAccess("franchisee_dashboard", "add"),
  316. );
  317. const canEditClasses = computed(() =>
  318. permissions.getAccess("franchisee_classes", "edit"),
  319. );
  320. const feriadosMes = computed(() => {
  321. const now = new Date();
  322. const month = now.getMonth() + 1;
  323. const year = now.getFullYear();
  324. return allHolidays.value
  325. .filter((holiday) => {
  326. const date = new Date(`${holiday.holiday_date}T00:00:00`);
  327. return (
  328. date.getMonth() + 1 === month &&
  329. date.getFullYear() === year
  330. );
  331. })
  332. .sort(
  333. (firstHoliday, secondHoliday) =>
  334. new Date(firstHoliday.holiday_date) -
  335. new Date(secondHoliday.holiday_date),
  336. )
  337. .map((holiday) => ({
  338. base_holiday_id: holiday.base_holiday_id ?? null,
  339. description: holiday.description,
  340. dia: new Date(`${holiday.holiday_date}T00:00:00`).getDate(),
  341. holiday_date: holiday.holiday_date,
  342. id: holiday.id,
  343. nome: holiday.description,
  344. type: holiday.type,
  345. }));
  346. });
  347. const matriculasChart = computed(() => {
  348. const now = new Date();
  349. const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
  350. const counts = {};
  351. for (const student of students.value) {
  352. if (!student.created_at) continue;
  353. const date = new Date(student.created_at.replace(" ", "T"));
  354. if (date < start) continue;
  355. const key = `${date.getFullYear()}-${date.getMonth()}`;
  356. counts[key] = (counts[key] ?? 0) + 1;
  357. }
  358. const data = [];
  359. const labels = [];
  360. for (let index = 0; index < 6; index++) {
  361. const month = new Date(
  362. start.getFullYear(),
  363. start.getMonth() + index,
  364. 1,
  365. );
  366. const key = `${month.getFullYear()}-${month.getMonth()}`;
  367. labels.push(MONTHS_PT[month.getMonth()]);
  368. data.push(counts[key] ?? 0);
  369. }
  370. return {
  371. datasets: [
  372. {
  373. color: MATRICULA_COLORS,
  374. data,
  375. label: "Matrículas",
  376. },
  377. ],
  378. labels,
  379. };
  380. });
  381. // Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda),
  382. // igual ao Franqueador.
  383. // TODO: alimentar com dados reais de faturamento de serviço/materiais.
  384. const faturamentoChart = {
  385. datasets: [
  386. {
  387. color: "#a274f1",
  388. data: [],
  389. label: "Serviço",
  390. },
  391. {
  392. color: "#ff9999",
  393. data: [],
  394. label: "Materiais",
  395. },
  396. ],
  397. labels: [],
  398. };
  399. const gaugeNeedlePlugin = {
  400. id: "gaugeNeedle",
  401. afterDatasetsDraw(chart) {
  402. const { ctx, data } = chart;
  403. const meta = chart.getDatasetMeta(0).data[0];
  404. const dataset = data.datasets[0];
  405. const needleValue = dataset.needleValue;
  406. const total = dataset.data.reduce((sum, value) => sum + value, 0) || 1;
  407. const outerRadius = meta.outerRadius - 20;
  408. const xCenter = meta.x;
  409. const yCenter = meta.y;
  410. const fraction = Math.min(needleValue, total) / total;
  411. const angle = Math.PI;
  412. ctx.save();
  413. ctx.translate(xCenter, yCenter);
  414. ctx.rotate(angle * (fraction + 1.5));
  415. ctx.beginPath();
  416. ctx.fillStyle = "grey";
  417. ctx.strokeStyle = "grey";
  418. ctx.moveTo(-3, 0);
  419. ctx.lineTo(0, -outerRadius);
  420. ctx.lineTo(3, 0);
  421. ctx.stroke();
  422. ctx.fill();
  423. ctx.beginPath();
  424. ctx.arc(0, 0, 6, 0, 2 * Math.PI);
  425. ctx.fillStyle = "grey";
  426. ctx.fill();
  427. ctx.restore();
  428. },
  429. };
  430. const MATRICULA_COLORS = [
  431. "#3B82F6",
  432. "#EF4444",
  433. "#A855F7",
  434. "#374151",
  435. "#EAB308",
  436. "#06B6D4",
  437. ];
  438. const MONTHS_PT = [
  439. "JAN",
  440. "FEV",
  441. "MAR",
  442. "ABR",
  443. "MAI",
  444. "JUN",
  445. "JUL",
  446. "AGO",
  447. "SET",
  448. "OUT",
  449. "NOV",
  450. "DEZ",
  451. ];
  452. const fetchHolidays = async () => {
  453. feriadosLoading.value = true;
  454. try {
  455. allHolidays.value = await getHolidays();
  456. } catch {
  457. $q.notify({
  458. message: "Erro ao carregar feriados.",
  459. type: "negative",
  460. });
  461. } finally {
  462. feriadosLoading.value = false;
  463. }
  464. };
  465. const fetchSummary = async () => {
  466. try {
  467. const summary = await getDashboardSummary();
  468. activeContracts.value = summary.contracts.active;
  469. aniversariantes.value = summary.birthdays;
  470. students.value = summary.enrollments;
  471. studentsByStatus.value = summary.students_by_status ?? studentsByStatus.value;
  472. totalAlunos.value = summary.students_count;
  473. if (summary.unit_config) {
  474. unitConfig.value = summary.unit_config;
  475. }
  476. } catch {
  477. // silencioso
  478. }
  479. };
  480. const formatCurrencyTick = (value) => {
  481. if (value >= 1000) {
  482. return `R$ ${(value / 1000).toFixed(0)}k`;
  483. }
  484. return `R$ ${value}`;
  485. };
  486. const formatCurrencyTooltip = (context) => {
  487. const value = context.parsed.y;
  488. return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", {
  489. minimumFractionDigits: 2,
  490. })}`;
  491. };
  492. const onCriarContrato = () => {
  493. $q.dialog({
  494. component: AddEditContractDialog,
  495. componentProps: {
  496. selectStudent: true,
  497. },
  498. }).onOk(fetchSummary);
  499. };
  500. const onRegistrarPresenca = () => {
  501. router.push({ name: "ClassPage" });
  502. };
  503. const openStudentsDialog = () => {
  504. $q.dialog({
  505. component: StudentsStatusDialog,
  506. });
  507. };
  508. const openEditFromDashboard = (feriado) => {
  509. $q.dialog({
  510. component: FeriadosEditDialog,
  511. componentProps: {
  512. holiday: feriado,
  513. },
  514. }).onOk(({ action, holiday: updated, id }) => {
  515. if (action === "update") {
  516. const index = allHolidays.value.findIndex(
  517. (holiday) => holiday.id === updated.id,
  518. );
  519. if (index !== -1) {
  520. allHolidays.value[index] = {
  521. ...allHolidays.value[index],
  522. ...updated,
  523. };
  524. }
  525. return;
  526. }
  527. if (action === "delete") {
  528. allHolidays.value = allHolidays.value.filter(
  529. (holiday) => holiday.id !== id,
  530. );
  531. }
  532. });
  533. };
  534. const openFeriadosDialog = () => {
  535. $q.dialog({
  536. component: FeriadosDialog,
  537. }).onOk(fetchHolidays);
  538. };
  539. watch(
  540. () => store.selectedUnit?.id,
  541. (newId, oldId) => {
  542. if (!newId || newId === oldId) return;
  543. fetchSummary();
  544. fetchHolidays();
  545. },
  546. );
  547. onMounted(() => {
  548. fetchHolidays();
  549. fetchSummary();
  550. });
  551. </script>
  552. <style scoped>
  553. .stat-cards-row {
  554. display: flex;
  555. flex-wrap: nowrap;
  556. gap: 16px;
  557. }
  558. .stat-cards-row > * {
  559. flex: 1 1 0;
  560. min-width: 0;
  561. }
  562. .gauge-label {
  563. position: absolute;
  564. bottom: 28%;
  565. left: 50%;
  566. pointer-events: none;
  567. text-align: center;
  568. transform: translateX(-50%);
  569. }
  570. .feriados-card {
  571. display: flex;
  572. flex-direction: column;
  573. padding: 20px 24px;
  574. border-radius: 12px;
  575. }
  576. @media (max-width: 599px) {
  577. .stat-cards-row {
  578. flex-wrap: wrap;
  579. }
  580. .stat-cards-row > * {
  581. flex: 1 1 calc(50% - 8px);
  582. }
  583. }
  584. </style>