Преглед на файлове

feat(filter): adiciona filtros de unidades e grupos em dash

ebagabee преди 3 седмици
родител
ревизия
9604be8bc2

+ 8 - 4
src/api/student.js

@@ -5,13 +5,17 @@ export const getStudents = async () => {
   return data.payload;
 };
 
-export const getStudentSummaryFranchisor = async () => {
-  const { data } = await api.get("/student/franchisor/summary");
+export const getStudentSummaryFranchisor = async (unitIds = []) => {
+  const { data } = await api.get("/student/franchisor/summary", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
   return data.payload;
 };
 
-export const getFranchisorActiveStudents = async () => {
-  const { data } = await api.get("/student/franchisor/active");
+export const getFranchisorActiveStudents = async (unitIds = []) => {
+  const { data } = await api.get("/student/franchisor/active", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
   return data.payload;
 };
 

+ 12 - 6
src/api/student_contract.js

@@ -5,17 +5,23 @@ export const getContractsByStudent = async (studentId) => {
   return data.payload;
 };
 
-export const getFranchisorContractSummary = async () => {
-  const { data } = await api.get("/student-contract/franchisor/summary");
+export const getFranchisorContractSummary = async (unitIds = []) => {
+  const { data } = await api.get("/student-contract/franchisor/summary", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
   return data.payload;
 };
 
-export const getFranchisorFrozenContracts = async () => {
-  const { data } = await api.get("/student-contract/franchisor/frozen");
+export const getFranchisorFrozenContracts = async (unitIds = []) => {
+  const { data } = await api.get("/student-contract/franchisor/frozen", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
   return data.payload;
 };
 
-export const getFranchisorCancelledContracts = async () => {
-  const { data } = await api.get("/student-contract/franchisor/cancelled");
+export const getFranchisorCancelledContracts = async (unitIds = []) => {
+  const { data } = await api.get("/student-contract/franchisor/cancelled", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
   return data.payload;
 };

+ 68 - 0
src/components/selects/GroupSelect.vue

@@ -0,0 +1,68 @@
+<template>
+  <DefaultSelect
+    v-model="selectedGroup"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="filteredOptions"
+    :loading="isLoading"
+    :label
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          Nenhum grupo encontrado
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { onMounted, ref } from "vue";
+import { getGroups } from "src/api/group";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label } = defineProps({
+  label: { type: String, default: "Grupo" },
+});
+
+// model value: { label, value, unit_ids } | null
+const selectedGroup = defineModel({ type: Object });
+
+const groupOptions = ref([]);
+const filteredOptions = ref([]);
+const isLoading = ref(true);
+
+const filterFn = (val, update) => {
+  update(() => {
+    if (val === "") {
+      filteredOptions.value = groupOptions.value;
+    } else {
+      const needle = val.toLowerCase();
+      filteredOptions.value = groupOptions.value.filter((v) =>
+        v.label.toLowerCase().includes(needle),
+      );
+    }
+  });
+};
+
+onMounted(async () => {
+  try {
+    const response = await getGroups();
+    groupOptions.value = response.map((g) => ({
+      label: g.name,
+      value: g.id,
+      unit_ids: g.unit_ids ?? [],
+    }));
+    filteredOptions.value = groupOptions.value;
+  } catch (error) {
+    console.error("Failed to load groups:", error);
+  } finally {
+    isLoading.value = false;
+  }
+});
+</script>

+ 141 - 188
src/pages/dashboard/DashboardPage.vue

@@ -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>
 

+ 5 - 1
src/pages/dashboard/components/ActiveStudentsDialog.vue

@@ -87,6 +87,10 @@ import { useDialogPluginComponent } from "quasar";
 import { useRouter } from "vue-router";
 import { getFranchisorActiveStudents } from "src/api/student";
 
+const props = defineProps({
+  unitIds: { type: Array, default: () => [] },
+});
+
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } =
@@ -100,7 +104,7 @@ const students = ref([]);
 onMounted(async () => {
   loading.value = true;
   try {
-    const data = await getFranchisorActiveStudents();
+    const data = await getFranchisorActiveStudents(props.unitIds);
     students.value = data.map((s) => ({
       id: s.id,
       name: s.name,

+ 5 - 1
src/pages/dashboard/components/CancelledContractsDialog.vue

@@ -83,6 +83,10 @@ import { ref, computed, onMounted } from "vue";
 import { useDialogPluginComponent } from "quasar";
 import { getFranchisorCancelledContracts } from "src/api/student_contract";
 
+const props = defineProps({
+  unitIds: { type: Array, default: () => [] },
+});
+
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
@@ -94,7 +98,7 @@ const contracts = ref([]);
 onMounted(async () => {
   loading.value = true;
   try {
-    const data = await getFranchisorCancelledContracts();
+    const data = await getFranchisorCancelledContracts(props.unitIds);
     contracts.value = data.map((c) => ({
       id: c.id,
       studentName: c.student_name ?? "—",

+ 5 - 1
src/pages/dashboard/components/FrozenContractsDialog.vue

@@ -83,6 +83,10 @@ import { ref, computed, onMounted } from "vue";
 import { useDialogPluginComponent } from "quasar";
 import { getFranchisorFrozenContracts } from "src/api/student_contract";
 
+const props = defineProps({
+  unitIds: { type: Array, default: () => [] },
+});
+
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
@@ -94,7 +98,7 @@ const contracts = ref([]);
 onMounted(async () => {
   loading.value = true;
   try {
-    const data = await getFranchisorFrozenContracts();
+    const data = await getFranchisorFrozenContracts(props.unitIds);
     contracts.value = data.map((c) => ({
       id: c.id,
       studentName: c.student_name ?? "—",