DashboardPage.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <template>
  2. <div>
  3. <DefaultHeaderPage
  4. title="Dashboard"
  5. :filter-open="showFilter"
  6. @show-filter="showFilter = !showFilter"
  7. />
  8. <div class="q-pa-sm">
  9. <div class="filter-row">
  10. <template v-if="showFilter">
  11. <!-- Unidade: ao selecionar, limpa o grupo -->
  12. <UnitSelect
  13. v-model="selectedUnit"
  14. dense
  15. label="Unidade"
  16. class="filter-item"
  17. color="secondary"
  18. @update:model-value="onUnitSelected"
  19. />
  20. <!-- Grupo: ao selecionar, limpa a unidade -->
  21. <GroupSelect
  22. v-model="selectedGroup"
  23. dense
  24. label="Grupo"
  25. class="filter-item"
  26. color="secondary"
  27. @update:model-value="onGroupSelected"
  28. />
  29. <DefaultSelect
  30. v-model="selectedPeriod"
  31. :options="periodOptions"
  32. option-value="value"
  33. option-label="label"
  34. emit-value
  35. map-options
  36. dense
  37. label="Selecione o Período"
  38. color="secondary"
  39. class="filter-item"
  40. />
  41. <template v-if="selectedPeriod === 'custom'">
  42. <DefaultInputDatePicker
  43. v-model="startDate"
  44. dense
  45. label="Data Inicial"
  46. color="secondary"
  47. class="filter-item"
  48. />
  49. <DefaultInputDatePicker
  50. v-model="endDate"
  51. dense
  52. label="Data Final"
  53. color="secondary"
  54. class="filter-item"
  55. />
  56. </template>
  57. </template>
  58. </div>
  59. <!-- Chip de contexto ativo -->
  60. <div v-if="showFilter && filterLabel" class="q-mt-xs q-ml-xs">
  61. <q-chip
  62. dense
  63. color="secondary"
  64. text-color="white"
  65. icon="mdi-filter"
  66. :label="filterLabel"
  67. removable
  68. @remove="clearFilters"
  69. />
  70. </div>
  71. </div>
  72. <div v-if="!isLoading" class="column gap q-pa-sm">
  73. <div class="stat-cards-row">
  74. <DashboardStatCard
  75. title="Total alunos (contratos ativos)"
  76. icon="mdi-account-multiple-outline"
  77. :value="totalStudents"
  78. :badge="`${activeContracts} ativos`"
  79. clickable
  80. @click="openActiveStudentsDialog"
  81. />
  82. <DashboardStatCard
  83. title="Contratos Congelados"
  84. icon="mdi-snowflake"
  85. :value="frozenContracts"
  86. subtitle="É hora de incentivar nossos alunos"
  87. clickable
  88. @click="openFrozenContractsDialog"
  89. />
  90. <DashboardStatCard
  91. title="Contratos Cancelados"
  92. icon="mdi-cancel"
  93. :value="cancelledContracts"
  94. subtitle="É hora de incentivar nossos alunos"
  95. clickable
  96. @click="openCancelledContractsDialog"
  97. />
  98. <DashboardStatCard
  99. title="Receita Geral"
  100. icon="mdi-currency-usd"
  101. value="R$ 0,00"
  102. subtitle="0 pagamentos pendentes"
  103. />
  104. </div>
  105. <div class="stat-cards-row">
  106. <DashboardStatCard
  107. title="Frequência Média"
  108. icon="mdi-account-multiple-outline"
  109. value="0%"
  110. clickable
  111. @click="openAverageAttendanceDialog"
  112. />
  113. <DashboardStatCard
  114. title="Estoque Geral de Produtos"
  115. icon="mdi-currency-usd"
  116. value="0"
  117. clickable
  118. @click="openProductStockDialog"
  119. />
  120. <DashboardStatCard
  121. title="Tarefas Pendentes"
  122. icon="mdi-draw"
  123. value="0"
  124. subtitle="Não deixe para amanhã"
  125. />
  126. <DashboardStatCard
  127. title="Tickets Abertos"
  128. icon="mdi-calendar-outline"
  129. value="0"
  130. subtitle="Estável"
  131. clickable
  132. @click="openTicketsDialog"
  133. />
  134. </div>
  135. <div class="charts-row">
  136. <DashboardChartCard title="Faturamento Serviço / Materiais">
  137. <GroupedBarChart
  138. :labels="faturamentoChart.labels"
  139. :datasets="faturamentoChart.datasets"
  140. label-y="R$"
  141. :tick-formatter="formatCurrencyTick"
  142. :tooltip-formatter="formatCurrencyTooltip"
  143. class="full-width full-height"
  144. />
  145. </DashboardChartCard>
  146. <DashboardChartCard title="Matrículas por Período">
  147. <GroupedBarChart
  148. :labels="matriculasChart.labels"
  149. :datasets="matriculasChart.datasets"
  150. :bar-radius="50"
  151. :show-datalabels="true"
  152. :max-bar-thickness="44"
  153. :category-percentage="0.6"
  154. :bar-percentage="0.85"
  155. class="full-width full-height"
  156. />
  157. </DashboardChartCard>
  158. <HolidaysCard />
  159. <BirthdaysCard :people="birthdays" />
  160. </div>
  161. </div>
  162. <div v-else class="flex flex-center full-width q-pa-xl">
  163. <q-spinner color="primary" size="50px" />
  164. </div>
  165. </div>
  166. </template>
  167. <script setup>
  168. import { onMounted, ref, computed, watch } from "vue";
  169. import { useQuasar } from "quasar";
  170. import { useI18n } from "vue-i18n";
  171. import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
  172. import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
  173. import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
  174. import UnitSelect from "src/components/selects/UnitSelect.vue";
  175. import GroupSelect from "src/components/selects/GroupSelect.vue";
  176. import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
  177. import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
  178. import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
  179. import BirthdaysCard from "src/components/charts/BirthdaysCard.vue";
  180. import HolidaysCard from "src/components/charts/HolidaysCard.vue";
  181. import ActiveStudentsDialog from "src/pages/dashboard/components/ActiveStudentsDialog.vue";
  182. import FrozenContractsDialog from "src/pages/dashboard/components/FrozenContractsDialog.vue";
  183. import CancelledContractsDialog from "src/pages/dashboard/components/CancelledContractsDialog.vue";
  184. import AverageAttendanceDialog from "src/pages/dashboard/components/AverageAttendanceDialog.vue";
  185. import ProductStockDialog from "src/pages/dashboard/components/ProductStockDialog.vue";
  186. import OpenTicketsDialog from "src/pages/dashboard/components/OpenTicketsDialog.vue";
  187. import { getStudentSummaryFranchisor } from "src/api/student";
  188. import { getFranchisorContractSummary } from "src/api/student_contract";
  189. useI18n(); // i18n instance kept for future use
  190. const $q = useQuasar();
  191. // ─── Loading state ───────────────────────────────────────────
  192. const isLoading = ref(true);
  193. // ─── Metric counters ─────────────────────────────────────────
  194. const totalStudents = ref(0);
  195. const activeContracts = ref(0);
  196. const frozenContracts = ref(0);
  197. const cancelledContracts = ref(0);
  198. // ─── Filter state ────────────────────────────────────────────
  199. const showFilter = ref(false);
  200. const selectedUnit = ref(null); // { label, value } | null
  201. const selectedGroup = ref(null); // { label, value, unit_ids } | null
  202. const selectedPeriod = ref("custom");
  203. const startDate = ref("");
  204. const endDate = ref("");
  205. const periodOptions = [
  206. { label: "Hoje", value: "today" },
  207. { label: "Esta semana", value: "week" },
  208. { label: "Este mês", value: "month" },
  209. { label: "Este ano", value: "year" },
  210. { label: "Personalizado", value: "custom" },
  211. ];
  212. /**
  213. * Array of unit IDs to use as filter.
  214. * - Group selected → all unit IDs in that group
  215. * - Unit selected → just that unit ID
  216. * - Nothing selected → [] (no filter = all units)
  217. */
  218. const activeUnitIds = computed(() => {
  219. if (selectedGroup.value?.unit_ids?.length) {
  220. return selectedGroup.value.unit_ids;
  221. }
  222. if (selectedUnit.value?.value) {
  223. return [selectedUnit.value.value];
  224. }
  225. return [];
  226. });
  227. /** Human-readable label shown in the active-filter chip */
  228. const filterLabel = computed(() => {
  229. if (selectedGroup.value) return `Grupo: ${selectedGroup.value.label}`;
  230. if (selectedUnit.value) return `Unidade: ${selectedUnit.value.label}`;
  231. return "";
  232. });
  233. // Mutual exclusion handlers
  234. const onUnitSelected = (val) => {
  235. if (val) selectedGroup.value = null;
  236. };
  237. const onGroupSelected = (val) => {
  238. if (val) selectedUnit.value = null;
  239. };
  240. const clearFilters = () => {
  241. selectedUnit.value = null;
  242. selectedGroup.value = null;
  243. };
  244. // ─── Dialog openers ──────────────────────────────────────────
  245. const openActiveStudentsDialog = () => {
  246. $q.dialog({ component: ActiveStudentsDialog, componentProps: { unitIds: activeUnitIds.value } });
  247. };
  248. const openFrozenContractsDialog = () => {
  249. $q.dialog({ component: FrozenContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
  250. };
  251. const openCancelledContractsDialog = () => {
  252. $q.dialog({ component: CancelledContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
  253. };
  254. const openAverageAttendanceDialog = () => {
  255. $q.dialog({ component: AverageAttendanceDialog });
  256. };
  257. const openProductStockDialog = () => {
  258. $q.dialog({ component: ProductStockDialog });
  259. };
  260. const openTicketsDialog = () => {
  261. $q.dialog({ component: OpenTicketsDialog });
  262. };
  263. // ─── Data fetching ───────────────────────────────────────────
  264. const fetchMetrics = async () => {
  265. const ids = activeUnitIds.value;
  266. const [studentSummary, contractSummary] = await Promise.all([
  267. getStudentSummaryFranchisor(ids),
  268. getFranchisorContractSummary(ids),
  269. ]);
  270. totalStudents.value = studentSummary.total;
  271. activeContracts.value = studentSummary.active;
  272. frozenContracts.value = contractSummary.frozen;
  273. cancelledContracts.value = contractSummary.cancelled;
  274. };
  275. // Re-fetch whenever unit/group filter changes
  276. watch(activeUnitIds, async () => {
  277. isLoading.value = true;
  278. try {
  279. await Promise.all([fetchMetrics(), updateDashboardData()]);
  280. } finally {
  281. isLoading.value = false;
  282. }
  283. });
  284. // ─── Mock data (charts still mocked) ─────────────────────────
  285. const birthdays = [
  286. { day: 10, name: "Heloisa Faria" },
  287. { day: 11, name: "Juliana Costa" },
  288. { day: 16, name: "Juliana Costa" },
  289. { day: 23, name: "Fernando Almeida" },
  290. { day: 29, name: "Lucas Pereira" },
  291. { day: 34, name: "Sofia Martins" },
  292. ];
  293. const matriculasChart = {
  294. labels: ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN"],
  295. datasets: [
  296. {
  297. label: "Matrículas",
  298. data: [120, 200, 150, 80, 70, 110],
  299. color: ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"],
  300. },
  301. ],
  302. };
  303. const faturamentoChart = {
  304. labels: ["17/02","18/02","19/02","20/02","21/02","22/02","23/02","24/02","25/02","26/02"],
  305. datasets: [
  306. {
  307. label: "Serviço",
  308. data: [18500,21000,16400,22300,19800,17200,15800,24100,20500,27600],
  309. color: "#a274f1",
  310. },
  311. {
  312. label: "Materiais",
  313. data: [9200,10500,8100,11400,9800,8400,8700,12200,10100,13100],
  314. color: "#ff9999",
  315. },
  316. ],
  317. };
  318. const formatCurrencyTick = (value) => {
  319. if (value >= 1000) return `R$ ${(value / 1000).toFixed(0)}k`;
  320. return `R$ ${value}`;
  321. };
  322. const formatCurrencyTooltip = (context) => {
  323. const value = context.parsed.y;
  324. return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
  325. };
  326. // Legacy mock refs (kept so chart bindings don't break)
  327. const ordersChart = ref({});
  328. const participantsChart = ref({});
  329. const paymentsChart = ref({});
  330. const ticketsSoldChart = ref({});
  331. const generateMockData = () => {
  332. const createMiniChartData = (currentTotal, percentage) => ({
  333. current_total: currentTotal,
  334. percentage_change: percentage,
  335. trend_data: Array.from({ length: 10 }, () => Math.floor(Math.random() * 100)),
  336. });
  337. return {
  338. payments: createMiniChartData((Math.random() * 20000 + 5000).toFixed(2), (Math.random() * 20 - 5).toFixed(2)),
  339. orders: createMiniChartData(Math.floor(Math.random() * 500 + 50), (Math.random() * 15 - 5).toFixed(2)),
  340. tickets_sold: createMiniChartData(Math.floor(Math.random() * 1500 + 200), (Math.random() * 25 - 5).toFixed(2)),
  341. participants: createMiniChartData(Math.floor(Math.random() * 1000 + 100), (Math.random() * 10 - 5).toFixed(2)),
  342. };
  343. };
  344. const updateDashboardData = async () => {
  345. return new Promise((resolve) => {
  346. setTimeout(() => {
  347. const mockData = generateMockData();
  348. ordersChart.value = mockData.orders;
  349. participantsChart.value = mockData.participants;
  350. paymentsChart.value = mockData.payments;
  351. ticketsSoldChart.value = mockData.tickets_sold;
  352. resolve();
  353. }, 300);
  354. });
  355. };
  356. // ─── Init ─────────────────────────────────────────────────────
  357. onMounted(async () => {
  358. isLoading.value = true;
  359. try {
  360. await Promise.all([fetchMetrics(), updateDashboardData()]);
  361. } finally {
  362. isLoading.value = false;
  363. }
  364. });
  365. </script>
  366. <style scoped>
  367. .gap {
  368. gap: 16px;
  369. }
  370. .stat-cards-row {
  371. display: flex;
  372. flex-wrap: nowrap;
  373. gap: 16px;
  374. }
  375. .stat-cards-row > * {
  376. flex: 1 1 0;
  377. min-width: 0;
  378. }
  379. @media (max-width: 599px) {
  380. .stat-cards-row {
  381. flex-wrap: wrap;
  382. }
  383. .stat-cards-row > * {
  384. flex: 1 1 calc(50% - 8px);
  385. }
  386. }
  387. .charts-row {
  388. display: flex;
  389. flex-wrap: wrap;
  390. gap: 16px;
  391. }
  392. .charts-row > *:nth-child(1) {
  393. flex: 0 0 calc(50% - 8px);
  394. min-width: 0;
  395. }
  396. .charts-row > *:nth-child(2) {
  397. flex: 0 0 calc(50% - 8px);
  398. min-width: 0;
  399. }
  400. .charts-row > *:nth-child(3),
  401. .charts-row > *:nth-child(4) {
  402. flex: 1 1 calc(50% - 8px);
  403. min-width: 0;
  404. }
  405. @media (max-width: 599px) {
  406. .charts-row > * {
  407. flex: 1 1 100%;
  408. }
  409. }
  410. .filter-row {
  411. display: flex;
  412. flex-wrap: wrap;
  413. gap: 12px;
  414. align-items: center;
  415. }
  416. .filter-item {
  417. flex: 1 1 200px;
  418. min-width: 180px;
  419. max-width: 300px;
  420. }
  421. @media (max-width: 599px) {
  422. .filter-item {
  423. flex: 1 1 100%;
  424. max-width: 100%;
  425. }
  426. }
  427. </style>