DashboardPage.vue 16 KB

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