DashboardPage.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. <template>
  2. <div>
  3. <DefaultHeaderPage show-filter-icon class="q-pa-sm">
  4. <template #after>
  5. <div class="flex items-center no-wrap" style="gap: 12px">
  6. <q-select
  7. v-if="$q.screen.gt.xs"
  8. v-model="selectedUnit"
  9. dense
  10. :options="[]"
  11. label="Unidade"
  12. style="width: 250px; flex-shrink: 0"
  13. color="secondary"
  14. hide-dropdown-icon
  15. >
  16. <template #append>
  17. <q-icon name="mdi-map-marker-outline" color="secondary" />
  18. </template>
  19. </q-select>
  20. <div
  21. class="flex items-center no-wrap q-gutter-x-md q-px-sm q-ml-md"
  22. style="flex-shrink: 0"
  23. >
  24. <q-img src="icons/user-icon.jpg" class="avatar-circle" />
  25. <div
  26. v-if="$q.screen.gt.xs"
  27. class="column q-gutter-y-none"
  28. style="white-space: nowrap"
  29. >
  30. <span class="text-body2 text-center">Ana Laura</span>
  31. <span
  32. v-if="$q.screen.gt.sm"
  33. class="text-overline text-center"
  34. style="line-height: 1rem; font-weight: 400"
  35. >Gerente</span
  36. >
  37. </div>
  38. </div>
  39. <template v-if="$q.screen.gt.sm">
  40. <q-separator
  41. vertical
  42. style="height: 36px; width: 2px; flex-shrink: 0"
  43. color="dark"
  44. />
  45. <div
  46. class="column"
  47. style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
  48. >
  49. <span class="text-caption text-grey-6 text-primary text-center"
  50. >Ultimo acesso</span
  51. >
  52. <span class="text-caption text-primary text-center"
  53. >16/02/2026, 14:16</span
  54. >
  55. </div>
  56. </template>
  57. <div
  58. class="flex items-center no-wrap"
  59. style="gap: 2px; flex-shrink: 0"
  60. >
  61. <q-btn flat round dense icon="mdi-bell-badge" color="secondary" />
  62. <q-btn flat round dense icon="mdi-account" color="secondary" />
  63. <q-btn flat round dense icon="mdi-cog-outline" color="secondary" />
  64. </div>
  65. </div>
  66. </template>
  67. </DefaultHeaderPage>
  68. <div class="q-pa-sm">
  69. <div class="filter-row">
  70. <q-select
  71. v-model="selectedPeriod"
  72. :options="periodOptions"
  73. option-value="value"
  74. option-label="label"
  75. emit-value
  76. map-options
  77. dense
  78. label="Selecione o Período"
  79. color="secondary"
  80. class="filter-item"
  81. hide-dropdown-icon
  82. >
  83. <template #append>
  84. <q-icon name="mdi-chevron-down" color="secondary" />
  85. </template>
  86. </q-select>
  87. <template v-if="selectedPeriod === 'custom'">
  88. <q-input
  89. v-model="startDate"
  90. dense
  91. label="Data Inicial"
  92. mask="##/##/####"
  93. placeholder="DD/MM/AAAA"
  94. color="secondary"
  95. class="filter-item"
  96. >
  97. <template #append>
  98. <q-icon
  99. name="mdi-calendar"
  100. color="secondary"
  101. class="cursor-pointer"
  102. >
  103. <q-popup-proxy
  104. cover
  105. transition-show="scale"
  106. transition-hide="scale"
  107. >
  108. <q-date
  109. v-model="startDate"
  110. mask="DD/MM/YYYY"
  111. color="secondary"
  112. >
  113. <div class="row items-center justify-end">
  114. <q-btn v-close-popup label="OK" color="secondary" flat />
  115. </div>
  116. </q-date>
  117. </q-popup-proxy>
  118. </q-icon>
  119. </template>
  120. </q-input>
  121. <q-input
  122. v-model="endDate"
  123. dense
  124. label="Data Final"
  125. mask="##/##/####"
  126. placeholder="DD/MM/AAAA"
  127. color="secondary"
  128. class="filter-item"
  129. >
  130. <template #append>
  131. <q-icon
  132. name="mdi-calendar"
  133. color="secondary"
  134. class="cursor-pointer"
  135. >
  136. <q-popup-proxy
  137. cover
  138. transition-show="scale"
  139. transition-hide="scale"
  140. >
  141. <q-date v-model="endDate" mask="DD/MM/YYYY" color="secondary">
  142. <div class="row items-center justify-end">
  143. <q-btn v-close-popup label="OK" color="secondary" flat />
  144. </div>
  145. </q-date>
  146. </q-popup-proxy>
  147. </q-icon>
  148. </template>
  149. </q-input>
  150. </template>
  151. </div>
  152. </div>
  153. <div v-if="!isLoading" class="column gap q-pa-sm">
  154. <div class="stat-cards-row">
  155. <DashboardStatCard
  156. title="Total alunos (contratos ativos)"
  157. icon="mdi-account-multiple-outline"
  158. value="4.527"
  159. badge="3.200 ativos"
  160. clickable
  161. @click="openAlunosDialog"
  162. />
  163. <DashboardStatCard
  164. title="Contratos Congelados"
  165. icon="mdi-snowflake"
  166. value="57"
  167. subtitle="É hora de incentivar nossos alunos"
  168. clickable
  169. @click="openContratosCongeladosDialog"
  170. />
  171. <DashboardStatCard
  172. title="Contratos Cancelados"
  173. icon="mdi-cancel"
  174. value="57"
  175. subtitle="É hora de incentivar nossos alunos"
  176. />
  177. <DashboardStatCard
  178. title="Receita Geral"
  179. icon="mdi-currency-usd"
  180. value="R$ 51.548,80"
  181. subtitle="0 pagamentos pendentes"
  182. />
  183. </div>
  184. <div class="charts-row">
  185. <DashboardChartCard title="Faturamento Serviço / Materiais">
  186. <GroupedBarChart
  187. :labels="faturamentoChart.labels"
  188. :datasets="faturamentoChart.datasets"
  189. label-y="R$"
  190. :tick-formatter="formatCurrencyTick"
  191. :tooltip-formatter="formatCurrencyTooltip"
  192. class="full-width full-height"
  193. />
  194. </DashboardChartCard>
  195. <DashboardChartCard title="Matrículas por Período">
  196. <GroupedBarChart
  197. :labels="matriculasChart.labels"
  198. :datasets="matriculasChart.datasets"
  199. :bar-radius="50"
  200. :show-datalabels="true"
  201. :max-bar-thickness="44"
  202. :category-percentage="0.6"
  203. :bar-percentage="0.85"
  204. class="full-width full-height"
  205. />
  206. </DashboardChartCard>
  207. <AniversariantesCard :people="aniversariantes" />
  208. </div>
  209. <div class="stat-cards-row">
  210. <DashboardStatCard
  211. title="Frequência Média"
  212. icon="mdi-account-multiple-outline"
  213. value="87%"
  214. badge="Alta"
  215. badge-color="approved"
  216. custom-style="padding: 6px 24px"
  217. />
  218. <DashboardStatCard
  219. title="Estoque Geral de Produtos"
  220. icon="mdi-currency-usd"
  221. value="56"
  222. />
  223. <DashboardStatCard
  224. title="Tarefas Pendentes"
  225. icon="mdi-draw"
  226. value="4"
  227. subtitle="Não deixe para amanhã"
  228. />
  229. <DashboardStatCard
  230. title="Tickets Abertos"
  231. icon="mdi-calendar-outline"
  232. value="2"
  233. subtitle="Estável"
  234. />
  235. </div>
  236. </div>
  237. <div v-else class="flex flex-center full-width q-pa-xl">
  238. <q-spinner color="primary" size="50px" />
  239. </div>
  240. </div>
  241. </template>
  242. <script setup>
  243. import { onMounted, ref, watch } from "vue";
  244. import { useQuasar } from "quasar";
  245. import { useI18n } from "vue-i18n";
  246. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  247. import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
  248. import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
  249. import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
  250. import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
  251. import AlunosAtivosDialog from "src/pages/dashboard/components/AlunosAtivosDialog.vue";
  252. import ContratosCongeladosDialog from "src/pages/dashboard/components/ContratosCongeladosDialog.vue";
  253. const { t } = useI18n();
  254. const $q = useQuasar();
  255. const isLoading = ref(true);
  256. const openAlunosDialog = () => {
  257. $q.dialog({ component: AlunosAtivosDialog });
  258. };
  259. const openContratosCongeladosDialog = () => {
  260. $q.dialog({ component: ContratosCongeladosDialog });
  261. };
  262. const selectedUnit = ref(null);
  263. const defaultPeriod = ref("month");
  264. const defaultEventId = ref(1);
  265. const selectedPeriod = ref("custom");
  266. const startDate = ref("");
  267. const endDate = ref("");
  268. const periodOptions = [
  269. { label: "Hoje", value: "today" },
  270. { label: "Esta semana", value: "week" },
  271. { label: "Este mês", value: "month" },
  272. { label: "Este ano", value: "year" },
  273. { label: "Personalizado", value: "custom" },
  274. ];
  275. const aniversariantes = [
  276. { day: 10, name: "Heloisa Faria" },
  277. { day: 11, name: "Juliana Costa" },
  278. { day: 16, name: "Juliana Costa" },
  279. { day: 23, name: "Fernando Almeida" },
  280. { day: 29, name: "Lucas Pereira" },
  281. { day: 34, name: "Sofia Martins" },
  282. ];
  283. const matriculasChart = {
  284. labels: ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN"],
  285. datasets: [
  286. {
  287. label: "Matrículas",
  288. data: [120, 200, 150, 80, 70, 110],
  289. color: ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"],
  290. },
  291. ],
  292. };
  293. const faturamentoChart = {
  294. labels: [
  295. "17/02",
  296. "18/02",
  297. "19/02",
  298. "20/02",
  299. "21/02",
  300. "22/02",
  301. "23/02",
  302. "24/02",
  303. "25/02",
  304. "26/02",
  305. ],
  306. datasets: [
  307. {
  308. label: "Serviço",
  309. data: [
  310. 18500, 21000, 16400, 22300, 19800, 17200, 15800, 24100, 20500, 27600,
  311. ],
  312. color: "#a274f1",
  313. },
  314. {
  315. label: "Materiais",
  316. data: [9200, 10500, 8100, 11400, 9800, 8400, 8700, 12200, 10100, 13100],
  317. color: "#ff9999",
  318. },
  319. ],
  320. };
  321. const formatCurrencyTick = (value) => {
  322. if (value >= 1000) return `R$ ${(value / 1000).toFixed(0)}k`;
  323. return `R$ ${value}`;
  324. };
  325. const formatCurrencyTooltip = (context) => {
  326. const value = context.parsed.y;
  327. return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
  328. };
  329. const ordersChart = ref({});
  330. const participantsChart = ref({});
  331. const paymentsChart = ref({});
  332. const ticketsSoldChart = ref({});
  333. const eventTicketsByTypeChart = ref({});
  334. const eventParticipantsByCNPJAndCPF = ref({});
  335. const salesOverTimeLineChart = ref({});
  336. const eventSourcePieChart = ref({});
  337. const generateMockData = () => {
  338. const createMiniChartData = (currentTotal, percentage) => ({
  339. current_total: currentTotal,
  340. percentage_change: percentage,
  341. trend_data: Array.from({ length: 10 }, () =>
  342. Math.floor(Math.random() * 100),
  343. ),
  344. });
  345. const barChartDataRaw = [
  346. {
  347. label: t("dashboard.charts.tickets_by_type.labels.vip"),
  348. value: Math.floor(Math.random() * 300),
  349. },
  350. {
  351. label: t("dashboard.charts.tickets_by_type.labels.track"),
  352. value: Math.floor(Math.random() * 800),
  353. },
  354. {
  355. label: t("dashboard.charts.tickets_by_type.labels.box"),
  356. value: Math.floor(Math.random() * 400),
  357. },
  358. {
  359. label: t("dashboard.charts.tickets_by_type.labels.courtesy"),
  360. value: Math.floor(Math.random() * 50),
  361. },
  362. ];
  363. const doughnutDataRaw = [
  364. {
  365. label: t("common.terms.cpf"),
  366. value: Math.floor(Math.random() * 900 + 100),
  367. },
  368. {
  369. label: t("common.terms.cnpj"),
  370. value: Math.floor(Math.random() * 100 + 10),
  371. },
  372. ];
  373. const doughnutTotal = doughnutDataRaw.reduce(
  374. (sum, item) => sum + item.value,
  375. 0,
  376. );
  377. const lineChartDataRaw = [
  378. {
  379. label: t("common.months.january"),
  380. value: Math.floor(1200 + Math.random() * 500),
  381. },
  382. {
  383. label: t("common.months.february"),
  384. value: Math.floor(1900 + Math.random() * 500),
  385. },
  386. {
  387. label: t("common.months.march"),
  388. value: Math.floor(3000 + Math.random() * 500),
  389. },
  390. {
  391. label: t("common.months.april"),
  392. value: Math.floor(5000 + Math.random() * 500),
  393. },
  394. {
  395. label: t("common.months.may"),
  396. value: Math.floor(2300 + Math.random() * 500),
  397. },
  398. {
  399. label: t("common.months.june"),
  400. value: Math.floor(3200 + Math.random() * 500),
  401. },
  402. ];
  403. const pieDataRaw = [
  404. {
  405. label: t("dashboard.charts.registration_source.sources.instagram"),
  406. value: Math.floor(450 + Math.random() * 50),
  407. },
  408. {
  409. label: t("dashboard.charts.registration_source.sources.facebook"),
  410. value: Math.floor(250 + Math.random() * 50),
  411. },
  412. {
  413. label: t("dashboard.charts.registration_source.sources.google"),
  414. value: Math.floor(180 + Math.random() * 50),
  415. },
  416. {
  417. label: t("dashboard.charts.registration_source.sources.referral"),
  418. value: Math.floor(120 + Math.random() * 50),
  419. },
  420. ];
  421. const pieTotal = pieDataRaw.reduce((sum, item) => sum + item.value, 0);
  422. return {
  423. payments: createMiniChartData(
  424. (Math.random() * 20000 + 5000).toFixed(2),
  425. (Math.random() * 20 - 5).toFixed(2),
  426. ),
  427. orders: createMiniChartData(
  428. Math.floor(Math.random() * 500 + 50),
  429. (Math.random() * 15 - 5).toFixed(2),
  430. ),
  431. tickets_sold: createMiniChartData(
  432. Math.floor(Math.random() * 1500 + 200),
  433. (Math.random() * 25 - 5).toFixed(2),
  434. ),
  435. participants: createMiniChartData(
  436. Math.floor(Math.random() * 1000 + 100),
  437. (Math.random() * 10 - 5).toFixed(2),
  438. ),
  439. barData: {
  440. chart_data: barChartDataRaw,
  441. },
  442. doughnutData: {
  443. chart_data: doughnutDataRaw,
  444. current_total: doughnutTotal,
  445. },
  446. lineData: {
  447. chart_data: lineChartDataRaw,
  448. },
  449. pieData: {
  450. chart_data: pieDataRaw,
  451. current_total: pieTotal,
  452. },
  453. };
  454. };
  455. const updateDashboardData = async () => {
  456. isLoading.value = true;
  457. setTimeout(() => {
  458. const mockData = generateMockData();
  459. ordersChart.value = mockData.orders;
  460. participantsChart.value = mockData.participants;
  461. paymentsChart.value = mockData.payments;
  462. ticketsSoldChart.value = mockData.tickets_sold;
  463. eventTicketsByTypeChart.value = mockData.barData;
  464. eventParticipantsByCNPJAndCPF.value = mockData.doughnutData;
  465. salesOverTimeLineChart.value = mockData.lineData;
  466. eventSourcePieChart.value = mockData.pieData;
  467. isLoading.value = false;
  468. }, 500);
  469. };
  470. watch([defaultPeriod, defaultEventId], async () => {
  471. await updateDashboardData();
  472. });
  473. onMounted(async () => {
  474. await updateDashboardData();
  475. });
  476. </script>
  477. <style scoped>
  478. .gap {
  479. gap: 16px;
  480. }
  481. .avatar-circle {
  482. width: 36px;
  483. height: 36px;
  484. border-radius: 50%;
  485. flex-shrink: 0;
  486. }
  487. .stat-cards-row {
  488. display: flex;
  489. flex-wrap: nowrap;
  490. gap: 16px;
  491. }
  492. .stat-cards-row > * {
  493. flex: 1 1 0;
  494. min-width: 0;
  495. }
  496. @media (max-width: 599px) {
  497. .stat-cards-row {
  498. flex-wrap: wrap;
  499. }
  500. .stat-cards-row > * {
  501. flex: 1 1 calc(50% - 8px);
  502. }
  503. }
  504. .charts-row {
  505. display: flex;
  506. flex-wrap: wrap;
  507. gap: 16px;
  508. }
  509. .charts-row > *:nth-child(1) {
  510. flex: 0 0 calc(41.6667% - 11px);
  511. min-width: 0;
  512. }
  513. .charts-row > *:nth-child(2) {
  514. flex: 0 0 calc(33.3333% - 11px);
  515. min-width: 0;
  516. }
  517. .charts-row > *:nth-child(3) {
  518. flex: 0 0 calc(25% - 11px);
  519. min-width: 0;
  520. }
  521. @media (max-width: 599px) {
  522. .charts-row > * {
  523. flex: 1 1 100%;
  524. }
  525. }
  526. .filter-row {
  527. display: flex;
  528. flex-wrap: wrap;
  529. gap: 12px;
  530. align-items: center;
  531. }
  532. .filter-item {
  533. flex: 1 1 200px;
  534. min-width: 180px;
  535. max-width: 300px;
  536. }
  537. @media (max-width: 599px) {
  538. .filter-item {
  539. flex: 1 1 100%;
  540. max-width: 100%;
  541. }
  542. }
  543. </style>