|
|
@@ -9,12 +9,24 @@
|
|
|
<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
|
|
|
@@ -49,6 +61,19 @@
|
|
|
</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">
|
|
|
@@ -153,48 +178,104 @@
|
|
|
</template>
|
|
|
|
|
|
<script setup>
|
|
|
-import { onMounted, ref, watch } from "vue";
|
|
|
+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 { getStudentSummaryFranchisor } from "src/api/student";
|
|
|
-import { getFranchisorContractSummary } from "src/api/student_contract";
|
|
|
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";
|
|
|
|
|
|
-const { t } = useI18n();
|
|
|
-
|
|
|
+useI18n(); // i18n instance kept for future use
|
|
|
const $q = useQuasar();
|
|
|
|
|
|
+// ─── Loading state ───────────────────────────────────────────
|
|
|
const isLoading = ref(true);
|
|
|
|
|
|
-const totalStudents = ref(0);
|
|
|
-const activeContracts = ref(0);
|
|
|
-const frozenContracts = ref(0);
|
|
|
+// ─── Metric counters ─────────────────────────────────────────
|
|
|
+const totalStudents = ref(0);
|
|
|
+const activeContracts = ref(0);
|
|
|
+const frozenContracts = ref(0);
|
|
|
const cancelledContracts = ref(0);
|
|
|
|
|
|
+// ─── 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 });
|
|
|
+ $q.dialog({ component: ActiveStudentsDialog, componentProps: { unitIds: activeUnitIds.value } });
|
|
|
};
|
|
|
|
|
|
const openFrozenContractsDialog = () => {
|
|
|
- $q.dialog({ component: FrozenContractsDialog });
|
|
|
+ $q.dialog({ component: FrozenContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
|
|
|
};
|
|
|
|
|
|
const openCancelledContractsDialog = () => {
|
|
|
- $q.dialog({ component: CancelledContractsDialog });
|
|
|
+ $q.dialog({ component: CancelledContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
|
|
|
};
|
|
|
|
|
|
const openAverageAttendanceDialog = () => {
|
|
|
@@ -208,23 +289,32 @@ const openProductStockDialog = () => {
|
|
|
const openTicketsDialog = () => {
|
|
|
$q.dialog({ component: OpenTicketsDialog });
|
|
|
};
|
|
|
-const defaultPeriod = ref("month");
|
|
|
-const defaultEventId = ref(1);
|
|
|
|
|
|
-const showFilter = ref(false);
|
|
|
-const selectedUnit = ref(null);
|
|
|
-const selectedPeriod = ref("custom");
|
|
|
-const startDate = ref("");
|
|
|
-const endDate = ref("");
|
|
|
+// ─── Data fetching ───────────────────────────────────────────
|
|
|
+const fetchMetrics = async () => {
|
|
|
+ const ids = activeUnitIds.value;
|
|
|
+ const [studentSummary, contractSummary] = await Promise.all([
|
|
|
+ getStudentSummaryFranchisor(ids),
|
|
|
+ getFranchisorContractSummary(ids),
|
|
|
+ ]);
|
|
|
+
|
|
|
+ totalStudents.value = studentSummary.total;
|
|
|
+ activeContracts.value = studentSummary.active;
|
|
|
+ frozenContracts.value = contractSummary.frozen;
|
|
|
+ cancelledContracts.value = contractSummary.cancelled;
|
|
|
+};
|
|
|
|
|
|
-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" },
|
|
|
-];
|
|
|
+// Re-fetch whenever unit/group filter changes
|
|
|
+watch(activeUnitIds, async () => {
|
|
|
+ isLoading.value = true;
|
|
|
+ try {
|
|
|
+ await Promise.all([fetchMetrics(), updateDashboardData()]);
|
|
|
+ } finally {
|
|
|
+ isLoading.value = false;
|
|
|
+ }
|
|
|
+});
|
|
|
|
|
|
+// ─── Mock data (charts still mocked) ─────────────────────────
|
|
|
const birthdays = [
|
|
|
{ day: 10, name: "Heloisa Faria" },
|
|
|
{ day: 11, name: "Juliana Costa" },
|
|
|
@@ -246,29 +336,16 @@ const matriculasChart = {
|
|
|
};
|
|
|
|
|
|
const faturamentoChart = {
|
|
|
- labels: [
|
|
|
- "17/02",
|
|
|
- "18/02",
|
|
|
- "19/02",
|
|
|
- "20/02",
|
|
|
- "21/02",
|
|
|
- "22/02",
|
|
|
- "23/02",
|
|
|
- "24/02",
|
|
|
- "25/02",
|
|
|
- "26/02",
|
|
|
- ],
|
|
|
+ labels: ["17/02","18/02","19/02","20/02","21/02","22/02","23/02","24/02","25/02","26/02"],
|
|
|
datasets: [
|
|
|
{
|
|
|
label: "Serviço",
|
|
|
- data: [
|
|
|
- 18500, 21000, 16400, 22300, 19800, 17200, 15800, 24100, 20500, 27600,
|
|
|
- ],
|
|
|
+ data: [18500,21000,16400,22300,19800,17200,15800,24100,20500,27600],
|
|
|
color: "#a274f1",
|
|
|
},
|
|
|
{
|
|
|
label: "Materiais",
|
|
|
- data: [9200, 10500, 8100, 11400, 9800, 8400, 8700, 12200, 10100, 13100],
|
|
|
+ data: [9200,10500,8100,11400,9800,8400,8700,12200,10100,13100],
|
|
|
color: "#ff9999",
|
|
|
},
|
|
|
],
|
|
|
@@ -284,172 +361,48 @@ const formatCurrencyTooltip = (context) => {
|
|
|
return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
|
|
|
};
|
|
|
|
|
|
+// Legacy mock refs (kept so chart bindings don't break)
|
|
|
const ordersChart = ref({});
|
|
|
const participantsChart = ref({});
|
|
|
const paymentsChart = ref({});
|
|
|
const ticketsSoldChart = ref({});
|
|
|
-const eventTicketsByTypeChart = ref({});
|
|
|
-const eventParticipantsByCNPJAndCPF = ref({});
|
|
|
-const salesOverTimeLineChart = ref({});
|
|
|
-const eventSourcePieChart = ref({});
|
|
|
|
|
|
const generateMockData = () => {
|
|
|
const createMiniChartData = (currentTotal, percentage) => ({
|
|
|
current_total: currentTotal,
|
|
|
percentage_change: percentage,
|
|
|
- trend_data: Array.from({ length: 10 }, () =>
|
|
|
- Math.floor(Math.random() * 100),
|
|
|
- ),
|
|
|
+ trend_data: Array.from({ length: 10 }, () => Math.floor(Math.random() * 100)),
|
|
|
});
|
|
|
|
|
|
- const barChartDataRaw = [
|
|
|
- {
|
|
|
- label: t("dashboard.charts.tickets_by_type.labels.vip"),
|
|
|
- value: Math.floor(Math.random() * 300),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("dashboard.charts.tickets_by_type.labels.track"),
|
|
|
- value: Math.floor(Math.random() * 800),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("dashboard.charts.tickets_by_type.labels.box"),
|
|
|
- value: Math.floor(Math.random() * 400),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("dashboard.charts.tickets_by_type.labels.courtesy"),
|
|
|
- value: Math.floor(Math.random() * 50),
|
|
|
- },
|
|
|
- ];
|
|
|
-
|
|
|
- const doughnutDataRaw = [
|
|
|
- {
|
|
|
- label: t("common.terms.cpf"),
|
|
|
- value: Math.floor(Math.random() * 900 + 100),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("common.terms.cnpj"),
|
|
|
- value: Math.floor(Math.random() * 100 + 10),
|
|
|
- },
|
|
|
- ];
|
|
|
- const doughnutTotal = doughnutDataRaw.reduce(
|
|
|
- (sum, item) => sum + item.value,
|
|
|
- 0,
|
|
|
- );
|
|
|
-
|
|
|
- const lineChartDataRaw = [
|
|
|
- {
|
|
|
- label: t("common.months.january"),
|
|
|
- value: Math.floor(1200 + Math.random() * 500),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("common.months.february"),
|
|
|
- value: Math.floor(1900 + Math.random() * 500),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("common.months.march"),
|
|
|
- value: Math.floor(3000 + Math.random() * 500),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("common.months.april"),
|
|
|
- value: Math.floor(5000 + Math.random() * 500),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("common.months.may"),
|
|
|
- value: Math.floor(2300 + Math.random() * 500),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("common.months.june"),
|
|
|
- value: Math.floor(3200 + Math.random() * 500),
|
|
|
- },
|
|
|
- ];
|
|
|
-
|
|
|
- const pieDataRaw = [
|
|
|
- {
|
|
|
- label: t("dashboard.charts.registration_source.sources.instagram"),
|
|
|
- value: Math.floor(450 + Math.random() * 50),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("dashboard.charts.registration_source.sources.facebook"),
|
|
|
- value: Math.floor(250 + Math.random() * 50),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("dashboard.charts.registration_source.sources.google"),
|
|
|
- value: Math.floor(180 + Math.random() * 50),
|
|
|
- },
|
|
|
- {
|
|
|
- label: t("dashboard.charts.registration_source.sources.referral"),
|
|
|
- value: Math.floor(120 + Math.random() * 50),
|
|
|
- },
|
|
|
- ];
|
|
|
- const pieTotal = pieDataRaw.reduce((sum, item) => sum + item.value, 0);
|
|
|
-
|
|
|
return {
|
|
|
- payments: createMiniChartData(
|
|
|
- (Math.random() * 20000 + 5000).toFixed(2),
|
|
|
- (Math.random() * 20 - 5).toFixed(2),
|
|
|
- ),
|
|
|
- orders: createMiniChartData(
|
|
|
- Math.floor(Math.random() * 500 + 50),
|
|
|
- (Math.random() * 15 - 5).toFixed(2),
|
|
|
- ),
|
|
|
- tickets_sold: createMiniChartData(
|
|
|
- Math.floor(Math.random() * 1500 + 200),
|
|
|
- (Math.random() * 25 - 5).toFixed(2),
|
|
|
- ),
|
|
|
- participants: createMiniChartData(
|
|
|
- Math.floor(Math.random() * 1000 + 100),
|
|
|
- (Math.random() * 10 - 5).toFixed(2),
|
|
|
- ),
|
|
|
- barData: {
|
|
|
- chart_data: barChartDataRaw,
|
|
|
- },
|
|
|
- doughnutData: {
|
|
|
- chart_data: doughnutDataRaw,
|
|
|
- current_total: doughnutTotal,
|
|
|
- },
|
|
|
- lineData: {
|
|
|
- chart_data: lineChartDataRaw,
|
|
|
- },
|
|
|
- pieData: {
|
|
|
- chart_data: pieDataRaw,
|
|
|
- current_total: pieTotal,
|
|
|
- },
|
|
|
+ payments: createMiniChartData((Math.random() * 20000 + 5000).toFixed(2), (Math.random() * 20 - 5).toFixed(2)),
|
|
|
+ orders: createMiniChartData(Math.floor(Math.random() * 500 + 50), (Math.random() * 15 - 5).toFixed(2)),
|
|
|
+ tickets_sold: createMiniChartData(Math.floor(Math.random() * 1500 + 200), (Math.random() * 25 - 5).toFixed(2)),
|
|
|
+ participants: createMiniChartData(Math.floor(Math.random() * 1000 + 100), (Math.random() * 10 - 5).toFixed(2)),
|
|
|
};
|
|
|
};
|
|
|
|
|
|
const updateDashboardData = async () => {
|
|
|
- isLoading.value = true;
|
|
|
- setTimeout(() => {
|
|
|
- const mockData = generateMockData();
|
|
|
-
|
|
|
- ordersChart.value = mockData.orders;
|
|
|
- participantsChart.value = mockData.participants;
|
|
|
- paymentsChart.value = mockData.payments;
|
|
|
- ticketsSoldChart.value = mockData.tickets_sold;
|
|
|
-
|
|
|
- eventTicketsByTypeChart.value = mockData.barData;
|
|
|
- eventParticipantsByCNPJAndCPF.value = mockData.doughnutData;
|
|
|
- salesOverTimeLineChart.value = mockData.lineData;
|
|
|
- eventSourcePieChart.value = mockData.pieData;
|
|
|
-
|
|
|
- isLoading.value = false;
|
|
|
- }, 500);
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ setTimeout(() => {
|
|
|
+ const mockData = generateMockData();
|
|
|
+ ordersChart.value = mockData.orders;
|
|
|
+ participantsChart.value = mockData.participants;
|
|
|
+ paymentsChart.value = mockData.payments;
|
|
|
+ ticketsSoldChart.value = mockData.tickets_sold;
|
|
|
+ resolve();
|
|
|
+ }, 300);
|
|
|
+ });
|
|
|
};
|
|
|
|
|
|
-watch([defaultPeriod, defaultEventId], async () => {
|
|
|
- await updateDashboardData();
|
|
|
-});
|
|
|
-
|
|
|
+// ─── Init ─────────────────────────────────────────────────────
|
|
|
onMounted(async () => {
|
|
|
- getStudentSummaryFranchisor().then((summary) => {
|
|
|
- totalStudents.value = summary.total;
|
|
|
- activeContracts.value = summary.active;
|
|
|
- });
|
|
|
- getFranchisorContractSummary().then((summary) => {
|
|
|
- frozenContracts.value = summary.frozen;
|
|
|
- cancelledContracts.value = summary.cancelled;
|
|
|
- });
|
|
|
- await updateDashboardData();
|
|
|
+ isLoading.value = true;
|
|
|
+ try {
|
|
|
+ await Promise.all([fetchMetrics(), updateDashboardData()]);
|
|
|
+ } finally {
|
|
|
+ isLoading.value = false;
|
|
|
+ }
|
|
|
});
|
|
|
</script>
|
|
|
|