| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- <template>
- <div>
- <DefaultHeaderPage
- title="Dashboard"
- :filter-open="showFilter"
- @show-filter="showFilter = !showFilter"
- />
- <div class="q-pa-sm">
- <div class="filter-row">
- <template v-if="showFilter">
- <!-- Unidade: ao selecionar, limpa o grupo -->
- <UnitSelect
- v-model="selectedUnit"
- dense
- label="Unidade"
- class="filter-item"
- color="secondary"
- @update:model-value="onUnitSelected"
- />
- <!-- Grupo: ao selecionar, limpa a unidade -->
- <GroupSelect
- v-model="selectedGroup"
- dense
- label="Grupo"
- class="filter-item"
- color="secondary"
- @update:model-value="onGroupSelected"
- />
- <DefaultSelect
- v-model="selectedPeriod"
- :options="periodOptions"
- option-value="value"
- option-label="label"
- emit-value
- map-options
- dense
- label="Selecione o Período"
- color="secondary"
- class="filter-item"
- />
- <template v-if="selectedPeriod === 'custom'">
- <DefaultInputDatePicker
- v-model="startDate"
- dense
- label="Data Inicial"
- color="secondary"
- class="filter-item"
- />
- <DefaultInputDatePicker
- v-model="endDate"
- dense
- label="Data Final"
- color="secondary"
- class="filter-item"
- />
- </template>
- </template>
- </div>
- <!-- Chip de contexto ativo -->
- <div v-if="showFilter && filterLabel" class="q-mt-xs q-ml-xs">
- <q-chip
- dense
- color="secondary"
- text-color="white"
- icon="mdi-filter"
- :label="filterLabel"
- removable
- @remove="clearFilters"
- />
- </div>
- </div>
- <div v-if="!isLoading" class="column gap q-pa-sm">
- <div class="stat-cards-row">
- <DashboardStatCard
- title="Total alunos (contratos ativos)"
- icon="mdi-account-multiple-outline"
- :value="totalStudents"
- :badge="`${activeContracts} ativos`"
- clickable
- @click="openActiveStudentsDialog"
- />
- <DashboardStatCard
- title="Contratos Congelados"
- icon="mdi-snowflake"
- :value="frozenContracts"
- subtitle="É hora de incentivar nossos alunos"
- clickable
- @click="openFrozenContractsDialog"
- />
- <DashboardStatCard
- title="Contratos Cancelados"
- icon="mdi-cancel"
- :value="cancelledContracts"
- subtitle="É hora de incentivar nossos alunos"
- clickable
- @click="openCancelledContractsDialog"
- />
- <DashboardStatCard
- title="Receita Geral"
- icon="mdi-currency-usd"
- :value="formatToBRLCurrency(generalRevenue.value)"
- :subtitle="`${generalRevenue.pending_count} pagamentos pendentes`"
- />
- </div>
- <div class="stat-cards-row">
- <DashboardStatCard
- title="Frequência Média"
- icon="mdi-account-multiple-outline"
- value="0%"
- clickable
- @click="openAverageAttendanceDialog"
- />
- <DashboardStatCard
- title="Estoque Geral de Produtos"
- icon="mdi-currency-usd"
- :value="productStock"
- clickable
- @click="openProductStockDialog"
- />
- <DashboardStatCard
- title="Tarefas Pendentes"
- icon="mdi-draw"
- :value="pendingTasks"
- subtitle="Não deixe para amanhã"
- />
- <DashboardStatCard
- title="Tickets Abertos"
- icon="mdi-calendar-outline"
- :value="openTickets"
- subtitle="Estável"
- clickable
- @click="openTicketsDialog"
- />
- </div>
- <div class="charts-row">
- <DashboardChartCard title="Faturamento Serviço / Materiais">
- <GroupedBarChart
- :labels="faturamentoChart.labels"
- :datasets="faturamentoChart.datasets"
- label-y="R$"
- :tick-formatter="formatCurrencyTick"
- :tooltip-formatter="formatCurrencyTooltip"
- class="full-width full-height"
- />
- </DashboardChartCard>
- <DashboardChartCard title="Matrículas por Período">
- <GroupedBarChart
- :labels="matriculasChart.labels"
- :datasets="matriculasChart.datasets"
- :bar-radius="50"
- :show-datalabels="true"
- :max-bar-thickness="44"
- :category-percentage="0.6"
- :bar-percentage="0.85"
- class="full-width full-height"
- />
- </DashboardChartCard>
- <HolidaysCard />
- <BirthdaysCard :people="birthdays" />
- </div>
- </div>
- <div v-else class="flex flex-center full-width q-pa-xl">
- <q-spinner color="primary" size="50px" />
- </div>
- </div>
- </template>
- <script setup>
- import { onMounted, ref, computed, watch } from "vue";
- import { useQuasar } from "quasar";
- import { useI18n } from "vue-i18n";
- import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
- import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
- import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
- import UnitSelect from "src/components/selects/UnitSelect.vue";
- import GroupSelect from "src/components/selects/GroupSelect.vue";
- import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
- import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
- import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
- import BirthdaysCard from "src/components/charts/BirthdaysCard.vue";
- import HolidaysCard from "src/components/charts/HolidaysCard.vue";
- import ActiveStudentsDialog from "src/pages/dashboard/components/ActiveStudentsDialog.vue";
- import FrozenContractsDialog from "src/pages/dashboard/components/FrozenContractsDialog.vue";
- import CancelledContractsDialog from "src/pages/dashboard/components/CancelledContractsDialog.vue";
- import AverageAttendanceDialog from "src/pages/dashboard/components/AverageAttendanceDialog.vue";
- import ProductStockDialog from "src/pages/dashboard/components/ProductStockDialog.vue";
- import OpenTicketsDialog from "src/pages/dashboard/components/OpenTicketsDialog.vue";
- import { getStudentSummaryFranchisor } from "src/api/student";
- import { getFranchisorContractSummary } from "src/api/student_contract";
- import { getFranchisorDashboard } from "src/api/dashboard";
- import { formatToBRLCurrency } from "src/helpers/utils";
- useI18n(); // i18n instance kept for future use
- const $q = useQuasar();
- // ─── Loading state ───────────────────────────────────────────
- const isLoading = ref(true);
- // ─── Metric counters ─────────────────────────────────────────
- const totalStudents = ref(0);
- const activeContracts = ref(0);
- const frozenContracts = ref(0);
- const cancelledContracts = ref(0);
- const openTickets = ref(0);
- const pendingTasks = ref(0);
- const productStock = ref(0);
- const generalRevenue = ref({ value: 0, pending_count: 0 });
- const birthdays = ref([]);
- // ─── Filter state ────────────────────────────────────────────
- const showFilter = ref(false);
- const selectedUnit = ref(null); // { label, value } | null
- const selectedGroup = ref(null); // { label, value, unit_ids } | null
- const selectedPeriod = ref("custom");
- const startDate = ref("");
- const endDate = ref("");
- const periodOptions = [
- { label: "Hoje", value: "today" },
- { label: "Esta semana", value: "week" },
- { label: "Este mês", value: "month" },
- { label: "Este ano", value: "year" },
- { label: "Personalizado", value: "custom" },
- ];
- /**
- * Array of unit IDs to use as filter.
- * - Group selected → all unit IDs in that group
- * - Unit selected → just that unit ID
- * - Nothing selected → [] (no filter = all units)
- */
- const activeUnitIds = computed(() => {
- if (selectedGroup.value?.unit_ids?.length) {
- return selectedGroup.value.unit_ids;
- }
- if (selectedUnit.value?.value) {
- return [selectedUnit.value.value];
- }
- return [];
- });
- /** Human-readable label shown in the active-filter chip */
- const filterLabel = computed(() => {
- if (selectedGroup.value) return `Grupo: ${selectedGroup.value.label}`;
- if (selectedUnit.value) return `Unidade: ${selectedUnit.value.label}`;
- return "";
- });
- // Mutual exclusion handlers
- const onUnitSelected = (val) => {
- if (val) selectedGroup.value = null;
- };
- const onGroupSelected = (val) => {
- if (val) selectedUnit.value = null;
- };
- const clearFilters = () => {
- selectedUnit.value = null;
- selectedGroup.value = null;
- };
- // ─── Dialog openers ──────────────────────────────────────────
- const openActiveStudentsDialog = () => {
- $q.dialog({ component: ActiveStudentsDialog, componentProps: { unitIds: activeUnitIds.value } });
- };
- const openFrozenContractsDialog = () => {
- $q.dialog({ component: FrozenContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
- };
- const openCancelledContractsDialog = () => {
- $q.dialog({ component: CancelledContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
- };
- const openAverageAttendanceDialog = () => {
- $q.dialog({ component: AverageAttendanceDialog });
- };
- const openProductStockDialog = () => {
- $q.dialog({ component: ProductStockDialog });
- };
- const openTicketsDialog = () => {
- $q.dialog({ component: OpenTicketsDialog });
- };
- // Matrículas por Período — preenchido pela API (últimos 6 meses).
- const MATRICULA_COLORS = ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"];
- const matriculasChart = ref({
- labels: [],
- datasets: [{ label: "Matrículas", data: [], color: MATRICULA_COLORS }],
- });
- // ─── Data fetching ───────────────────────────────────────────
- const fetchMetrics = async () => {
- const ids = activeUnitIds.value;
- const [studentSummary, contractSummary, dashboard] = await Promise.all([
- getStudentSummaryFranchisor(ids),
- getFranchisorContractSummary(ids),
- getFranchisorDashboard(ids),
- ]);
- totalStudents.value = studentSummary.total;
- activeContracts.value = contractSummary.active ?? 0;
- frozenContracts.value = contractSummary.frozen;
- cancelledContracts.value = contractSummary.cancelled;
- openTickets.value = dashboard.open_tickets ?? 0;
- pendingTasks.value = dashboard.pending_tasks ?? 0;
- productStock.value = dashboard.product_stock ?? 0;
- generalRevenue.value = dashboard.general_revenue ?? { value: 0, pending_count: 0 };
- birthdays.value = dashboard.birthdays ?? [];
- matriculasChart.value = {
- labels: dashboard.enrollments?.labels ?? [],
- datasets: [
- {
- label: "Matrículas",
- data: dashboard.enrollments?.data ?? [],
- color: MATRICULA_COLORS,
- },
- ],
- };
- };
- // Re-fetch whenever unit/group filter changes
- watch(activeUnitIds, async () => {
- isLoading.value = true;
- try {
- await fetchMetrics();
- } finally {
- isLoading.value = false;
- }
- });
- // Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda).
- const faturamentoChart = {
- labels: [],
- datasets: [
- { label: "Serviço", data: [], color: "#a274f1" },
- { label: "Materiais", data: [], color: "#ff9999" },
- ],
- };
- const formatCurrencyTick = (value) => {
- if (value >= 1000) return `R$ ${(value / 1000).toFixed(0)}k`;
- return `R$ ${value}`;
- };
- const formatCurrencyTooltip = (context) => {
- const value = context.parsed.y;
- return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
- };
- // ─── Init ─────────────────────────────────────────────────────
- onMounted(async () => {
- isLoading.value = true;
- try {
- await fetchMetrics();
- } finally {
- isLoading.value = false;
- }
- });
- </script>
- <style scoped>
- .gap {
- gap: 16px;
- }
- .stat-cards-row {
- display: flex;
- flex-wrap: nowrap;
- gap: 16px;
- }
- .stat-cards-row > * {
- flex: 1 1 0;
- min-width: 0;
- }
- @media (max-width: 599px) {
- .stat-cards-row {
- flex-wrap: wrap;
- }
- .stat-cards-row > * {
- flex: 1 1 calc(50% - 8px);
- }
- }
- .charts-row {
- display: flex;
- flex-wrap: wrap;
- gap: 16px;
- }
- .charts-row > *:nth-child(1) {
- flex: 0 0 calc(50% - 8px);
- min-width: 0;
- }
- .charts-row > *:nth-child(2) {
- flex: 0 0 calc(50% - 8px);
- min-width: 0;
- }
- .charts-row > *:nth-child(3),
- .charts-row > *:nth-child(4) {
- flex: 1 1 calc(50% - 8px);
- min-width: 0;
- }
- @media (max-width: 599px) {
- .charts-row > * {
- flex: 1 1 100%;
- }
- }
- .filter-row {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- align-items: center;
- }
- .filter-item {
- flex: 1 1 200px;
- min-width: 180px;
- max-width: 300px;
- }
- @media (max-width: 599px) {
- .filter-item {
- flex: 1 1 100%;
- max-width: 100%;
- }
- }
- </style>
|