Explorar o código

✨ feat(dashboard): implementar página de dashboard com estatísticas e tabelas dinâmicas

Fase: dev | Origin: melhoria-interna
Gustavo Zanatta hai 1 semana
pai
achega
6b6c47f6e1
Modificáronse 1 ficheiros con 197 adicións e 16 borrados
  1. 197 16
      src/pages/dashboard/DashboardPage.vue

+ 197 - 16
src/pages/dashboard/DashboardPage.vue

@@ -1,36 +1,217 @@
 <template>
   <div>
-    <DefaultHeaderPage>
-    </DefaultHeaderPage>
+    <DefaultHeaderPage />
 
-    <div v-if="!isLoading" class="column gap q-pa-sm">
-      <div>Administrador: {{ store.userTipo }}</div>
-    </div>
+    <div class="q-pa-sm">
+      <div v-if="statsLoading" class="flex flex-center q-pa-xl">
+        <q-spinner color="violet-normal" size="50px" />
+      </div>
+
+      <template v-else>
+        <div class="row q-col-gutter-md q-mb-md">
+          <div
+            v-for="card in statCards"
+            :key="card.key"
+            class="col-12 col-sm-6 col-md-4 col-lg-2"
+          >
+            <q-card
+              flat
+              class="stat-card cursor-pointer bg-white"
+              :class="{ 'stat-card--active': activeCard === card.key }"
+              @click="onCardClick(card)"
+            >
+              <q-card-section class="q-pa-md">
+                <q-icon :name="card.icon" size="28px" color="violet-normal" />
+                <div class="text-h5 text-weight-bold q-mt-xs text-dark">
+                  {{ stats[card.key] ?? '—' }}
+                </div>
+                <div class="text-caption text-grey-7 ellipsis">
+                  {{ $t(card.labelKey) }}
+                </div>
+              </q-card-section>
+            </q-card>
+          </div>
+        </div>
+
+        <div v-if="activeCard">
+          <q-card flat class="bg-white q-pa-md" style="border-radius: 12px">
+            <div v-if="tableLoading" class="flex flex-center q-pa-xl">
+              <q-spinner color="violet-normal" size="40px" />
+            </div>
 
-    <div v-else class="flex flex-center full-width q-pa-xl">
-      <q-spinner color="primary" size="50px" />
+            <DefaultTable
+              v-else
+              v-model:rows="tableRows"
+              :columns="tableColumns"
+              no-api-call
+              :rows-per-page="10"
+              :show-search-field="false"
+            >
+              <template #body-cell-status="{ row }">
+                <q-td>
+                  <q-badge
+                    :color="getStatusColor(row.status)"
+                    :label="$t(getStatusI18nKey(row.status))"
+                    class="text-capitalize"
+                  />
+                </q-td>
+              </template>
+            </DefaultTable>
+          </q-card>
+        </div>
+      </template>
     </div>
   </div>
 </template>
 
 <script setup>
-import { onMounted, ref } from "vue";
+import { ref, computed, onMounted } from "vue";
+import { useI18n } from "vue-i18n";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import { userStore } from "src/stores/user";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import { getDashboardStats } from "src/api/dashboard";
+import { getUsers } from "src/api/user";
+import { getPartnerAgreements } from "src/api/partnerAgreement";
+import { formatDateYMDtoDMY, getStatusColor, getStatusI18nKey } from "src/helpers/utils";
+
+const { t } = useI18n();
+
+const statsLoading = ref(true);
+const tableLoading = ref(false);
+const stats = ref({});
+const activeCard = ref(null);
+const tableRows = ref([]);
+
+const usersCache = ref(null);
+const partnersCache = ref(null);
+
+const statCards = [
+  { key: "total_associados",    icon: "mdi-account-group",   labelKey: "dashboard.stats.total_associados" },
+  { key: "associados_ativos",   icon: "mdi-trending-up",     labelKey: "dashboard.stats.associados_ativos" },
+  { key: "parceiros",           icon: "mdi-handshake",       labelKey: "dashboard.stats.parceiros" },
+  { key: "contratos_a_vencer",  icon: "mdi-file-clock",      labelKey: "dashboard.stats.contratos_a_vencer" },
+  { key: "novos_mes",           icon: "mdi-account-plus",    labelKey: "dashboard.stats.novos_mes" },
+  { key: "associados_pendentes", icon: "mdi-account-alert",  labelKey: "dashboard.stats.associados_pendentes" },
+];
+
+const columnsAssociados = computed(() => [
+  { name: "name",       label: t("common.terms.name"),                   field: "name",       align: "left", sortable: true },
+  { name: "email",      label: t("common.terms.email"),                   field: "email",      align: "left", sortable: true },
+  { name: "created_at", label: t("dashboard.stats.association_date"),    field: (row) => formatDateYMDtoDMY(row.created_at), align: "left", sortable: true },
+  { name: "status",     label: t("common.terms.status"),                  field: "status",     align: "left" },
+]);
+
+const columnsParceiros = computed(() => [
+  { name: "company_name", label: t("common.terms.name"),               field: "company_name", align: "left", sortable: true },
+  { name: "responsible",  label: t("parceiro.responsible"),             field: "responsible",  align: "left", sortable: true },
+  { name: "created_at",   label: t("dashboard.stats.registration_date"), field: (row) => formatDateYMDtoDMY(row.created_at), align: "left", sortable: true },
+  { name: "status",       label: t("common.terms.status"),              field: "status",       align: "left" },
+]);
+
+const columnsContratosAVencer = computed(() => [
+  { name: "company_name",  label: t("common.terms.name"),          field: "company_name",  align: "left", sortable: true },
+  { name: "responsible",   label: t("parceiro.responsible"),        field: "responsible",   align: "left", sortable: true },
+  { name: "contract_end",  label: t("parceiro.contract_end"),       field: (row) => formatDateYMDtoDMY(row.contract_end), align: "left", sortable: true },
+  { name: "status",        label: t("common.terms.status"),         field: "status",        align: "left" },
+]);
+
+const tableColumns = computed(() => {
+  if (!activeCard.value) return [];
+  if (activeCard.value === "contratos_a_vencer") return columnsContratosAVencer.value;
+  if (activeCard.value === "parceiros" || activeCard.value === "novos_mes") {
+    return columnsParceiros.value;
+  }
+  return columnsAssociados.value;
+});
 
-const store = userStore();
 
-const isLoading = ref(true);
+
+const loadUsers = async () => {
+  if (usersCache.value) return usersCache.value;
+  const users = await getUsers();
+  usersCache.value = users;
+  return users;
+};
+
+const loadPartners = async () => {
+  if (partnersCache.value) return partnersCache.value;
+  const partners = await getPartnerAgreements();
+  partnersCache.value = partners;
+  return partners;
+};
+
+const isCurrentMonth = (dateStr) => {
+  if (!dateStr) return false;
+  const now = new Date();
+  const [datePart] = dateStr.split(" ");
+  const [year, month] = datePart.split("-");
+  return parseInt(year) === now.getFullYear() && parseInt(month) === now.getMonth() + 1;
+};
+
+const getStatusValue = (status) => typeof status === "object" ? status?.value : status;
+
+const onCardClick = async (card) => {
+  if (activeCard.value === card.key) return;
+
+  activeCard.value = card.key;
+  tableLoading.value = true;
+  tableRows.value = [];
+
+  try {
+    if (card.key === "contratos_a_vencer") {
+      const partners = await loadPartners();
+      const today = new Date();
+      today.setHours(0, 0, 0, 0);
+      const in30Days = new Date(today);
+      in30Days.setDate(in30Days.getDate() + 30);
+      tableRows.value = partners.filter((p) => {
+        if (!p.contract_end) return false;
+        const end = new Date(p.contract_end + "T00:00:00");
+        return end >= today && end <= in30Days;
+      });
+    } else if (card.key === "parceiros") {
+      const partners = await loadPartners();
+      tableRows.value = partners;
+    } else if (card.key === "novos_mes") {
+      const partners = await loadPartners();
+      tableRows.value = partners.filter((p) => isCurrentMonth(p.created_at));
+    } else {
+      const users = await loadUsers();
+      const associados = users.filter((u) => getStatusValue(u.type) === "associado");
+
+      if (card.key === "total_associados") {
+        tableRows.value = associados;
+      } else if (card.key === "associados_ativos") {
+        tableRows.value = associados.filter((u) => getStatusValue(u.status) === "active");
+      } else if (card.key === "associados_pendentes") {
+        tableRows.value = associados.filter((u) => getStatusValue(u.status) === "pending");
+      }
+    }
+  } finally {
+    tableLoading.value = false;
+  }
+};
 
 onMounted(async () => {
-  setTimeout(() => {
-    isLoading.value = false;
-  }, 1000);
+  try {
+    stats.value = await getDashboardStats();
+  } finally {
+    statsLoading.value = false;
+  }
 });
 </script>
 
 <style scoped>
-.gap {
-  gap: 16px;
+.stat-card {
+  border-radius: 12px;
+  transition: box-shadow 0.2s;
+}
+
+.stat-card:hover {
+  box-shadow: 0 2px 8px rgba(102, 29, 117, 0.15);
+}
+
+.stat-card--active {
+  box-shadow: 0 0 0 2px var(--q-violet-normal);
 }
 </style>