DashboardPage.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  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="formatToBRLCurrency(generalRevenue.value)"
  102. :subtitle="`${generalRevenue.pending_count} 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="productStock"
  117. clickable
  118. @click="openProductStockDialog"
  119. />
  120. <DashboardStatCard
  121. title="Tarefas Pendentes"
  122. icon="mdi-draw"
  123. :value="pendingTasks"
  124. subtitle="Não deixe para amanhã"
  125. />
  126. <DashboardStatCard
  127. title="Tickets Abertos"
  128. icon="mdi-calendar-outline"
  129. :value="openTickets"
  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. import { getFranchisorDashboard } from "src/api/dashboard";
  190. import { formatToBRLCurrency } from "src/helpers/utils";
  191. useI18n(); // i18n instance kept for future use
  192. const $q = useQuasar();
  193. // ─── Loading state ───────────────────────────────────────────
  194. const isLoading = ref(true);
  195. // ─── Metric counters ─────────────────────────────────────────
  196. const totalStudents = ref(0);
  197. const activeContracts = ref(0);
  198. const frozenContracts = ref(0);
  199. const cancelledContracts = ref(0);
  200. const openTickets = ref(0);
  201. const pendingTasks = ref(0);
  202. const productStock = ref(0);
  203. const generalRevenue = ref({ value: 0, pending_count: 0 });
  204. const birthdays = ref([]);
  205. // ─── Filter state ────────────────────────────────────────────
  206. const showFilter = ref(false);
  207. const selectedUnit = ref(null); // { label, value } | null
  208. const selectedGroup = ref(null); // { label, value, unit_ids } | null
  209. const selectedPeriod = ref("custom");
  210. const startDate = ref("");
  211. const endDate = ref("");
  212. const periodOptions = [
  213. { label: "Hoje", value: "today" },
  214. { label: "Esta semana", value: "week" },
  215. { label: "Este mês", value: "month" },
  216. { label: "Este ano", value: "year" },
  217. { label: "Personalizado", value: "custom" },
  218. ];
  219. /**
  220. * Array of unit IDs to use as filter.
  221. * - Group selected → all unit IDs in that group
  222. * - Unit selected → just that unit ID
  223. * - Nothing selected → [] (no filter = all units)
  224. */
  225. const activeUnitIds = computed(() => {
  226. if (selectedGroup.value?.unit_ids?.length) {
  227. return selectedGroup.value.unit_ids;
  228. }
  229. if (selectedUnit.value?.value) {
  230. return [selectedUnit.value.value];
  231. }
  232. return [];
  233. });
  234. /** Human-readable label shown in the active-filter chip */
  235. const filterLabel = computed(() => {
  236. if (selectedGroup.value) return `Grupo: ${selectedGroup.value.label}`;
  237. if (selectedUnit.value) return `Unidade: ${selectedUnit.value.label}`;
  238. return "";
  239. });
  240. // Mutual exclusion handlers
  241. const onUnitSelected = (val) => {
  242. if (val) selectedGroup.value = null;
  243. };
  244. const onGroupSelected = (val) => {
  245. if (val) selectedUnit.value = null;
  246. };
  247. const clearFilters = () => {
  248. selectedUnit.value = null;
  249. selectedGroup.value = null;
  250. };
  251. // ─── Dialog openers ──────────────────────────────────────────
  252. const openActiveStudentsDialog = () => {
  253. $q.dialog({ component: ActiveStudentsDialog, componentProps: { unitIds: activeUnitIds.value } });
  254. };
  255. const openFrozenContractsDialog = () => {
  256. $q.dialog({ component: FrozenContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
  257. };
  258. const openCancelledContractsDialog = () => {
  259. $q.dialog({ component: CancelledContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
  260. };
  261. const openAverageAttendanceDialog = () => {
  262. $q.dialog({ component: AverageAttendanceDialog });
  263. };
  264. const openProductStockDialog = () => {
  265. $q.dialog({ component: ProductStockDialog });
  266. };
  267. const openTicketsDialog = () => {
  268. $q.dialog({ component: OpenTicketsDialog });
  269. };
  270. // Matrículas por Período — preenchido pela API (últimos 6 meses).
  271. const MATRICULA_COLORS = ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"];
  272. const matriculasChart = ref({
  273. labels: [],
  274. datasets: [{ label: "Matrículas", data: [], color: MATRICULA_COLORS }],
  275. });
  276. // ─── Data fetching ───────────────────────────────────────────
  277. const fetchMetrics = async () => {
  278. const ids = activeUnitIds.value;
  279. const [studentSummary, contractSummary, dashboard] = await Promise.all([
  280. getStudentSummaryFranchisor(ids),
  281. getFranchisorContractSummary(ids),
  282. getFranchisorDashboard(ids),
  283. ]);
  284. totalStudents.value = studentSummary.total;
  285. activeContracts.value = contractSummary.active ?? 0;
  286. frozenContracts.value = contractSummary.frozen;
  287. cancelledContracts.value = contractSummary.cancelled;
  288. openTickets.value = dashboard.open_tickets ?? 0;
  289. pendingTasks.value = dashboard.pending_tasks ?? 0;
  290. productStock.value = dashboard.product_stock ?? 0;
  291. generalRevenue.value = dashboard.general_revenue ?? { value: 0, pending_count: 0 };
  292. birthdays.value = dashboard.birthdays ?? [];
  293. matriculasChart.value = {
  294. labels: dashboard.enrollments?.labels ?? [],
  295. datasets: [
  296. {
  297. label: "Matrículas",
  298. data: dashboard.enrollments?.data ?? [],
  299. color: MATRICULA_COLORS,
  300. },
  301. ],
  302. };
  303. };
  304. // Re-fetch whenever unit/group filter changes
  305. watch(activeUnitIds, async () => {
  306. isLoading.value = true;
  307. try {
  308. await fetchMetrics();
  309. } finally {
  310. isLoading.value = false;
  311. }
  312. });
  313. // Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda).
  314. const faturamentoChart = {
  315. labels: [],
  316. datasets: [
  317. { label: "Serviço", data: [], color: "#a274f1" },
  318. { label: "Materiais", data: [], color: "#ff9999" },
  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. // ─── Init ─────────────────────────────────────────────────────
  330. onMounted(async () => {
  331. isLoading.value = true;
  332. try {
  333. await fetchMetrics();
  334. } finally {
  335. isLoading.value = false;
  336. }
  337. });
  338. </script>
  339. <style scoped>
  340. .gap {
  341. gap: 16px;
  342. }
  343. .stat-cards-row {
  344. display: flex;
  345. flex-wrap: nowrap;
  346. gap: 16px;
  347. }
  348. .stat-cards-row > * {
  349. flex: 1 1 0;
  350. min-width: 0;
  351. }
  352. @media (max-width: 599px) {
  353. .stat-cards-row {
  354. flex-wrap: wrap;
  355. }
  356. .stat-cards-row > * {
  357. flex: 1 1 calc(50% - 8px);
  358. }
  359. }
  360. .charts-row {
  361. display: flex;
  362. flex-wrap: wrap;
  363. gap: 16px;
  364. }
  365. .charts-row > *:nth-child(1) {
  366. flex: 0 0 calc(50% - 8px);
  367. min-width: 0;
  368. }
  369. .charts-row > *:nth-child(2) {
  370. flex: 0 0 calc(50% - 8px);
  371. min-width: 0;
  372. }
  373. .charts-row > *:nth-child(3),
  374. .charts-row > *:nth-child(4) {
  375. flex: 1 1 calc(50% - 8px);
  376. min-width: 0;
  377. }
  378. @media (max-width: 599px) {
  379. .charts-row > * {
  380. flex: 1 1 100%;
  381. }
  382. }
  383. .filter-row {
  384. display: flex;
  385. flex-wrap: wrap;
  386. gap: 12px;
  387. align-items: center;
  388. }
  389. .filter-item {
  390. flex: 1 1 200px;
  391. min-width: 180px;
  392. max-width: 300px;
  393. }
  394. @media (max-width: 599px) {
  395. .filter-item {
  396. flex: 1 1 100%;
  397. max-width: 100%;
  398. }
  399. }
  400. </style>