Преглед изворни кода

feat(dashboard): add Dashboard Metrics page and integrate with navigation

alvesantos пре 3 дана
родитељ
комит
036239908b

+ 16 - 1
src/components/charts/DashboardStatCard.vue

@@ -1,5 +1,9 @@
 <template>
-  <q-card class="stat-card">
+  <q-card
+    class="stat-card"
+    :class="{ 'stat-card--clickable': clickable }"
+    @click="clickable && emit('click')"
+  >
     <div class="flex justify-between items-start no-wrap">
       <span class="text-subtitle2 text-dark">{{ title }}</span>
       <q-icon :name="icon" size="22px" color="dark" />
@@ -43,7 +47,10 @@ defineProps({
   badgeColor: { type: String, default: "accent-1" },
   valueColor: { type: String, default: "" },
   customStyle: { type: String, default: "padding: 4px" },
+  clickable: { type: Boolean, default: false },
 });
+
+const emit = defineEmits(["click"]);
 </script>
 
 <style scoped lang="scss">
@@ -59,6 +66,14 @@ defineProps({
   justify-content: space-between;
 }
 
+.stat-card--clickable {
+  cursor: pointer;
+
+  &:hover {
+    box-shadow: 0 0 0 1px $primary !important;
+  }
+}
+
 .value-area {
   display: flex;
   flex-direction: column;

+ 124 - 0
src/pages/dashboard/DashboardMetricsPage.vue

@@ -0,0 +1,124 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Metas do Dashboard" />
+
+    <div class="q-pa-sm">
+      <q-card
+        bordered
+        class="q-pa-md"
+        flat
+        style="max-width: 640px"
+      >
+        <div class="text-subtitle1 text-weight-medium q-mb-xs">
+          Faixas de alunos ativos
+        </div>
+
+        <div class="text-caption text-grey-7 q-mb-md">
+          Define as metas usadas nos indicadores do Dashboard (cor do card
+          "Total alunos" e do medidor "Contratos Ativos"). 🔴 Abaixo do
+          mínimo, 🟡 entre mínimo e máximo, 🟢 a partir do máximo.
+        </div>
+
+        <DefaultForm
+          ref="formRef"
+          @submit="onSave"
+        >
+          <div class="row q-col-gutter-md">
+            <DefaultInput
+              v-model="form.active_students_min"
+              :error="!!validationErrors.active_students_min"
+              :error-message="validationErrors.active_students_min"
+              :rules="[inputRules.required]"
+              class="col-6"
+              label="🔴 Mínimo (vermelho até este valor)"
+              outlined
+              type="number"
+            />
+
+            <DefaultInput
+              v-model="form.active_students_max"
+              :error="!!validationErrors.active_students_max"
+              :error-message="validationErrors.active_students_max"
+              :rules="[inputRules.required]"
+              class="col-6"
+              label="🟢 Máximo (verde a partir deste valor)"
+              outlined
+              type="number"
+            />
+          </div>
+
+          <div class="row justify-end q-mt-md">
+            <q-btn
+              v-if="canEdit"
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :disable="!hasUpdatedFields"
+              :loading="loading"
+            />
+          </div>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, useTemplateRef } from "vue";
+import { computed } from "vue";
+import { getUnitMe, updateUnitMe } from "src/api/unit";
+import { permissionStore } from "src/stores/permission";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const { inputRules } = useInputRules();
+const permissions = permissionStore();
+
+const formRef = useTemplateRef("formRef");
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_unit", "edit"),
+);
+
+const { form, getUpdatedFields, hasUpdatedFields, setUpdateFormAsOriginal } =
+  useForm({
+    active_students_min: null,
+    active_students_max: null,
+  });
+
+const { loading, validationErrors, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: () => {
+    setUpdateFormAsOriginal();
+  },
+});
+
+const onSave = async () => {
+  await execute(async () => {
+    const changedFields = { ...getUpdatedFields.value };
+
+    if (Object.keys(changedFields).length) {
+      await updateUnitMe(changedFields);
+    }
+  });
+};
+
+onMounted(async () => {
+  try {
+    const unit = await getUnitMe();
+
+    form.active_students_min = unit.active_students_min ?? 30;
+    form.active_students_max = unit.active_students_max ?? 80;
+
+    setUpdateFormAsOriginal();
+  } catch (error) {
+    console.error(error);
+  }
+});
+</script>

+ 45 - 44
src/pages/dashboard/DashboardPage.vue

@@ -8,8 +8,10 @@
           :badges="studentsStatusBadges"
           :value="String(totalAlunos)"
           :value-color="activeStudentsColor"
+          clickable
           icon="mdi-account-multiple-outline"
           title="Total alunos"
+          @click="openStudentsDialog"
         />
 
         <!-- TODO: usar dados reais de receita (zerado por enquanto). -->
@@ -285,6 +287,7 @@ import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
 import FeriadosDialog from "./components/FeriadosDialog.vue";
 import FeriadosEditDialog from "./components/FeriadosEditDialog.vue";
+import StudentsStatusDialog from "./components/StudentsStatusDialog.vue";
 
 ChartJS.register(ArcElement, Tooltip, Legend);
 
@@ -303,47 +306,50 @@ const studentsByStatus = ref({ active: 0, ex_student: 0, lead: 0, locked: 0 });
 const totalAlunos = ref(0);
 const unitConfig = ref({
   active_students_min: 30,
-  active_students_medium: 50,
   active_students_max: 80,
 });
 
 const activeStudentsColor = computed(() => {
   const count = totalAlunos.value;
   const min = unitConfig.value.active_students_min;
-  const med = unitConfig.value.active_students_medium;
+  const max = unitConfig.value.active_students_max;
 
   if (count < min) return "negative"; // Vermelho
-  if (count >= min && count < med) return "warning"; // Amarelo
-  return "positive"; // Verde (>= med)
+  if (count < max) return "warning"; // Amarelo
+  return "positive"; // Verde (>= max)
 });
 
 const studentsStatusBadges = computed(() => [
-  { color: "info", label: `${studentsByStatus.value.lead} Lead` },
   { color: "positive", label: `${studentsByStatus.value.active} Ativo` },
-  { color: "grey", label: `${studentsByStatus.value.ex_student} Ex-aluno` },
-  { color: "warning", label: `${studentsByStatus.value.locked} Trancado` },
+  { color: "orange", label: `${studentsByStatus.value.locked} Trancado` },
+  { color: "grey-7", label: `${studentsByStatus.value.ex_student} Ex-aluno` },
+  { color: "blue", label: `${studentsByStatus.value.lead} Lead` },
 ]);
 
-const gaugeData = ref({
-  datasets: [
-    {
-      backgroundColor: [
-        "#00a550",
-        "#4dbb7e",
-        "#9ad2ad",
-        "#cce156",
-        "#fff100",
-        "#ffbe00",
-        "#ff8c00",
-        "#FC3D23",
-        "#D01616",
-        "#8A0000",
-      ],
-      borderColor: "transparent",
-      data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
-      needleValue: 0,
-    },
-  ],
+// Faixas do medidor "Contratos Ativos" — mesma meta configurável de
+// alunos ativos (Dashboard > Metas do Dashboard): vermelho até o mínimo,
+// amarelo entre mínimo e máximo, verde a partir do máximo.
+const gaugeScaleMax = computed(() => {
+  const max = Math.max(unitConfig.value.active_students_max, 1);
+
+  return max * 1.2;
+});
+
+const gaugeData = computed(() => {
+  const min = Math.max(unitConfig.value.active_students_min, 0);
+  const max = Math.max(unitConfig.value.active_students_max, min + 1);
+  const scaleMax = Math.max(gaugeScaleMax.value, max + 1);
+
+  return {
+    datasets: [
+      {
+        backgroundColor: ["#D01616", "#ffbe00", "#00a550"],
+        borderColor: "transparent",
+        data: [min, max - min, scaleMax - max],
+        needleValue: Math.min(activeContracts.value, scaleMax),
+      },
+    ],
+  };
 });
 
 const gaugeOptions = ref({
@@ -352,12 +358,7 @@ const gaugeOptions = ref({
   maintainAspectRatio: false,
   plugins: {
     datalabels: {
-      color: "black",
-      font: {
-        size: 14,
-        weight: "bold",
-      },
-      formatter: (_value, context) => context.dataIndex,
+      display: false,
     },
     legend: {
       display: false,
@@ -484,20 +485,19 @@ const gaugeNeedlePlugin = {
     const { ctx, data } = chart;
 
     const meta = chart.getDatasetMeta(0).data[0];
-    const needleValue = data.datasets[0].needleValue;
+    const dataset = data.datasets[0];
+    const needleValue = dataset.needleValue;
+    const total = dataset.data.reduce((sum, value) => sum + value, 0) || 1;
     const outerRadius = meta.outerRadius - 20;
     const xCenter = meta.x;
     const yCenter = meta.y;
 
-    const circumference =
-      (meta.circumference / Math.PI / data.datasets[0].data[0]) *
-      needleValue;
-
+    const fraction = Math.min(needleValue, total) / total;
     const angle = Math.PI;
 
     ctx.save();
     ctx.translate(xCenter, yCenter);
-    ctx.rotate(angle * (circumference + 1.5));
+    ctx.rotate(angle * (fraction + 1.5));
 
     ctx.beginPath();
     ctx.fillStyle = "grey";
@@ -569,11 +569,6 @@ const fetchSummary = async () => {
     if (summary.unit_config) {
       unitConfig.value = summary.unit_config;
     }
-
-    gaugeData.value.datasets[0].needleValue = Math.min(
-      activeContracts.value,
-      10,
-    );
   } catch {
     // silencioso
   }
@@ -608,6 +603,12 @@ const onRegistrarPresenca = () => {
   router.push({ name: "ClassPage" });
 };
 
+const openStudentsDialog = () => {
+  $q.dialog({
+    component: StudentsStatusDialog,
+  });
+};
+
 const openEditFromDashboard = (feriado) => {
   $q.dialog({
     component: FeriadosEditDialog,

+ 156 - 0
src/pages/dashboard/components/StudentsStatusDialog.vue

@@ -0,0 +1,156 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card style="width: 700px; max-width: 95vw; border-radius: 12px">
+      <q-bar class="bg-transparent q-px-md" style="height: 55px">
+        <span class="text-h6 text-dark" style="font-weight: 600">Alunos</span>
+        <q-space />
+        <q-btn dense flat icon="mdi-close" @click="onDialogCancel" />
+      </q-bar>
+
+      <q-card-section class="q-pt-none q-pb-md q-px-md">
+        <q-card flat bordered style="border-radius: 8px">
+          <q-card-section class="q-pb-xs">
+            <div class="text-subtitle2 text-dark">Lista de alunos</div>
+            <div class="text-caption text-grey-6">
+              {{ students.length }} Alunos
+              <q-spinner
+                v-if="loading"
+                class="q-ml-sm"
+                color="primary"
+                size="16px"
+              />
+            </div>
+          </q-card-section>
+
+          <q-card-section class="q-pt-xs q-pb-sm">
+            <q-input
+              v-model="search"
+              borderless
+              dense
+              placeholder="Busque por nome ou telefone"
+            >
+              <template #prepend>
+                <q-icon color="grey-6" name="mdi-magnify" />
+              </template>
+            </q-input>
+          </q-card-section>
+
+          <q-separator />
+
+          <div class="list-header q-px-md q-py-xs">
+            <span class="text-caption text-grey-7">Nome</span>
+            <span class="text-caption text-grey-7">Telefone</span>
+            <span class="text-caption text-grey-7">Status</span>
+          </div>
+
+          <q-separator />
+
+          <div style="max-height: 320px; overflow-y: auto">
+            <template
+              v-for="(student, index) in filteredStudents"
+              :key="student.id"
+            >
+              <div class="list-row q-px-md q-py-sm">
+                <span class="text-body2 text-dark">{{ student.name }}</span>
+                <span class="text-caption text-dark">{{ student.phone }}</span>
+                <q-badge
+                  :color="statusColor(student.status)"
+                  :label="statusLabel(student.status)"
+                  style="
+                    border-radius: 8px;
+                    font-size: 11px;
+                    padding: 4px;
+                    width: max-content;
+                    margin-left: 10px;
+                  "
+                />
+              </div>
+              <q-separator v-if="index < filteredStudents.length - 1" />
+            </template>
+
+            <div
+              v-if="!loading && filteredStudents.length === 0"
+              class="text-caption text-grey-6 text-center q-pa-md"
+            >
+              Nenhum aluno encontrado.
+            </div>
+          </div>
+        </q-card>
+      </q-card-section>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { getStudents } from "src/api/student";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const search = ref("");
+const loading = ref(false);
+const students = ref([]);
+
+onMounted(async () => {
+  loading.value = true;
+
+  try {
+    const data = await getStudents();
+
+    students.value = data.map((student) => ({
+      id: student.id,
+      name: student.name,
+      phone: student.phone ?? "—",
+      status: student.status,
+    }));
+  } catch {
+    // silencioso
+  } finally {
+    loading.value = false;
+  }
+});
+
+const filteredStudents = computed(() => {
+  if (!search.value) return students.value;
+
+  const query = search.value.toLowerCase();
+
+  return students.value.filter(
+    (student) =>
+      student.name.toLowerCase().includes(query) ||
+      student.phone.includes(query),
+  );
+});
+
+const statusLabel = (status) => {
+  if (status === "active") return "Ativo";
+  if (status === "locked") return "Trancado";
+  if (status === "ex_student") return "Ex-aluno";
+  return "Lead";
+};
+
+const statusColor = (status) => {
+  if (status === "active") return "positive";
+  if (status === "locked") return "orange";
+  if (status === "ex_student") return "grey-7";
+  return "blue";
+};
+</script>
+
+<style scoped>
+.list-header {
+  display: grid;
+  grid-template-columns: 1fr 1fr 100px;
+  align-items: center;
+}
+
+.list-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr 100px;
+  align-items: center;
+  align-content: center;
+}
+</style>

+ 0 - 43
src/pages/unit/tabs/UnitDataTab.vue

@@ -189,42 +189,6 @@
               outlined
             />
 
-            <div class="col-12 q-mt-md">
-              <div class="text-subtitle2 text-grey-7 q-mb-sm">
-                Configurações de Dashboard (Metas de Alunos)
-              </div>
-            </div>
-
-            <DefaultInput
-              v-model="form.active_students_min"
-              type="number"
-              :error="!!validationErrors.active_students_min"
-              :error-message="validationErrors.active_students_min"
-              class="col-4"
-              label="Mínimo de Alunos (🔴 Abaixo deste valor)"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="form.active_students_medium"
-              type="number"
-              :error="!!validationErrors.active_students_medium"
-              :error-message="validationErrors.active_students_medium"
-              class="col-4"
-              label="Meta Média (🟡 Entre mín e médio)"
-              outlined
-            />
-
-            <DefaultInput
-              v-model="form.active_students_max"
-              type="number"
-              :error="!!validationErrors.active_students_max"
-              :error-message="validationErrors.active_students_max"
-              class="col-4"
-              label="Meta Máxima (🟢 Acima da média)"
-              outlined
-            />
-
             <div class="col-12 q-mt-sm">
               <div class="text-subtitle2 text-grey-7 q-mb-sm">
                 Alterar Senha
@@ -358,9 +322,6 @@ const {
   state_id: null,
   state_registration: null,
   street: null,
-  active_students_min: null,
-  active_students_medium: null,
-  active_students_max: null,
 });
 
 const hasChanges = computed(
@@ -457,10 +418,6 @@ onMounted(async () => {
 
     form.street = unit.street;
 
-    form.active_students_min = unit.active_students_min ?? 30;
-    form.active_students_medium = unit.active_students_medium ?? 50;
-    form.active_students_max = unit.active_students_max ?? 80;
-
     cityName.value = unit.city?.name ?? "";
     stateName.value = unit.state?.name ?? "";
 

+ 16 - 0
src/router/routes/unit.route.js

@@ -15,4 +15,20 @@ export default [
       ],
     },
   },
+  {
+    path: "/dashboard/metas",
+    name: "DashboardMetricsPage",
+    component: () => import("pages/dashboard/DashboardMetricsPage.vue"),
+    meta: {
+      title: { value: "Metas do Dashboard", translate: false },
+      requireAuth: true,
+      requiredPermission: "franchisee_unit",
+      breadcrumbs: [
+        {
+          name: "DashboardMetricsPage",
+          title: "Metas do Dashboard",
+        },
+      ],
+    },
+  },
 ];

+ 9 - 0
src/stores/navigation.js

@@ -149,6 +149,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "franchisee_unit",
     },
+    {
+      type: "single",
+      title: "Metas do Dashboard",
+      name: "DashboardMetricsPage",
+      icon: "mdi-target",
+      disable: false,
+      permission: false,
+      permissionScope: "franchisee_unit",
+    },
     {
       type: "single",
       title: "Suporte",