Эх сурвалжийг харах

feat: :sparkles: feat (dashboard) novos filtros de pesquisa

foram adicionados filtros de pesquisa nos blocos da dashboard

fase:dev | origin:escopo
Gustavo Zanatta 4 өдөр өмнө
parent
commit
97ee52b80e

+ 7 - 5
src/api/partnerAgreement.js

@@ -7,12 +7,14 @@ export const getPartnerAgreements = async ({ type } = {}) => {
   return data.payload;
 };
 
-export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, expiresInDays, createdMonth, type } = {}) => {
+export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, status, expiresInDays, createdMonth, type, withStatusOptions } = {}) => {
   const params = { page, per_page: perPage };
-  if (filter)        params.search          = filter;
-  if (expiresInDays) params.expires_in_days = expiresInDays;
-  if (createdMonth)  params.created_month   = createdMonth;
-  if (type)          params.type            = type;
+  if (filter)            params.search             = filter;
+  if (status)            params.status             = status;
+  if (expiresInDays)     params.expires_in_days    = expiresInDays;
+  if (createdMonth)      params.created_month      = createdMonth;
+  if (type)              params.type               = type;
+  if (withStatusOptions) params.with_status_options = true;
   const { data } = await api.get("/partner-agreement/paginated", { params });
   return { data: { result: data.payload } };
 };

+ 2 - 0
src/api/user.js

@@ -62,6 +62,7 @@ export const getUsersPaginated = async ({
   status,
   position_id,
   sector_id,
+  withStatusOptions,
 } = {}) => {
   const params = { page, per_page: perPage };
   if (type)        params.type        = type;
@@ -69,6 +70,7 @@ export const getUsersPaginated = async ({
   if (filter)      params.search      = filter;
   if (position_id) params.position_id = position_id;
   if (sector_id)   params.sector_id   = sector_id;
+  if (withStatusOptions) params.with_status_options = true;
   const { data } = await api.get("/user/paginated", { params });
   return { data: { result: data.payload } };
 };

+ 2 - 0
src/api/userAccessLog.js

@@ -3,12 +3,14 @@ import api from "src/api";
 export const getAccessLogsPaginated = async ({
   page = 1,
   perPage = 10,
+  filter,
   type,
   date_from,
   date_to,
   last_per_user,
 } = {}) => {
   const params = { page, per_page: perPage };
+  if (filter) params.search = filter;
   if (type) params.type = type;
   if (date_from) params.date_from = date_from;
   if (date_to) params.date_to = date_to;

+ 31 - 12
src/components/defaults/DefaultTableServerSide.vue

@@ -26,7 +26,7 @@
           outlined
           dense
           debounce="500"
-          :placeholder="$t('associado.search_placeholder')"
+          :placeholder="searchPlaceholder || $t('associado.search_placeholder')"
           clearable
           autofocus
           color="violet-normal"
@@ -130,7 +130,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, toRaw, watch } from "vue";
+import { ref, computed, onMounted, onUnmounted, toRaw, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 
@@ -154,6 +154,7 @@ const {
   addItemRoute,
   rowsPerPage,
   showSearchField,
+  searchPlaceholder,
   hideNoDataLabel,
   deleteFunction,
   semTableTop,
@@ -204,6 +205,10 @@ const {
     type: Boolean,
     default: true,
   },
+  searchPlaceholder: {
+    type: String,
+    default: "",
+  },
   noApiRoute: {
     type: Boolean,
     default: false,
@@ -308,11 +313,9 @@ const getPaginationLabel = (from, to, total) => {
   return `${from}-${to} ${t?.("common.ui.table.of") ?? "of"} ${total}`;
 };
 
-let isFetching = false;
+let requestId = 0;
 const onRequest = async () => {
-  if (isFetching) return;
-
-  isFetching = true;
+  const currentRequest = ++requestId;
   loading.value = true;
 
   try {
@@ -323,6 +326,8 @@ const onRequest = async () => {
       ...extraFilters,
     });
 
+    if (currentRequest !== requestId) return;
+
     rows.value = response.data.result.data;
     pagination.value.rowsNumber = response.data.result.total;
     pagination.value.from = response.data.result.from;
@@ -332,22 +337,31 @@ const onRequest = async () => {
       emit("noRows");
     }
   } catch (error) {
+    if (currentRequest !== requestId) return;
     console.error("Error fetching data:", error);
   } finally {
-    loading.value = false;
-    isFetching = false;
+    if (currentRequest === requestId) {
+      loading.value = false;
+    }
   }
 };
 
+onUnmounted(() => {
+  requestId++;
+});
+
 watch(
   () => apiCall,
-  () => onRequest(),
+  () => {
+    pagination.value.page = 1;
+    onRequest();
+  },
 );
 
 watch(
   () => pagination.value.filter,
   (newFilter, oldFilter) => {
-    if (newFilter === oldFilter || loading.value) return;
+    if (newFilter === oldFilter) return;
     pagination.value.page = 1;
     onRequest();
   },
@@ -356,16 +370,21 @@ watch(
 watch(
   () => pagination.value.rowsPerPage,
   (newVal, oldVal) => {
-    if (newVal === oldVal || loading.value) return;
+    if (newVal === oldVal) return;
     pagination.value.page = 1;
     onRequest();
   },
 );
 
+let lastExtraFilters = JSON.stringify(extraFilters ?? {});
+
 watch(
   () => extraFilters,
   () => {
-    if (loading.value) return;
+    const snapshot = JSON.stringify(extraFilters ?? {});
+    if (snapshot === lastExtraFilters) return;
+
+    lastExtraFilters = snapshot;
     pagination.value.page = 1;
     onRequest();
   },

+ 11 - 1
src/i18n/locales/en.json

@@ -621,10 +621,16 @@
   },
   "dashboard": {
     "currency_format": "$ {value}",
+    "filters": {
+      "search_associados": "Search by name, e-mail, CPF or badge",
+      "search_parceiros": "Search by company, contact or CNPJ",
+      "search_dependentes": "Search by dependent, member or date",
+      "search_acessos": "Search by user, type or date"
+    },
     "stats": {
       "total_associados": "Total Members",
       "associados_ativos": "Active Members",
-      "parceiros": "Partners",
+      "parceiros": "Partners / Agreements",
       "contratos_a_vencer": "Expiring Contracts",
       "novos_mes": "New (month)",
       "associados_pendentes": "Pending Members",
@@ -703,6 +709,10 @@
     "whatsapp_btn_send": "Open WhatsApp"
   },
   "parceiro": {
+    "type": {
+      "partner": "Partner",
+      "agreement": "Agreement"
+    },
     "category": "Category",
     "company_name": "Company Name",
     "responsible": "Responsible",

+ 11 - 1
src/i18n/locales/es.json

@@ -622,10 +622,16 @@
   },
   "dashboard": {
     "currency_format": "R$ {value}",
+    "filters": {
+      "search_associados": "Buscar por nombre, correo, CPF o credencial",
+      "search_parceiros": "Buscar por empresa, responsable o CNPJ",
+      "search_dependentes": "Buscar por dependiente, asociado o fecha",
+      "search_acessos": "Buscar por usuario, tipo o fecha"
+    },
     "stats": {
       "total_associados": "Total Asociados",
       "associados_ativos": "Asociados Activos",
-      "parceiros": "Socios",
+      "parceiros": "Socios / Convenios",
       "contratos_a_vencer": "Contratos por Vencer",
       "novos_mes": "Nuevos (mes)",
       "associados_pendentes": "Asociados Pendientes",
@@ -703,6 +709,10 @@
     "whatsapp_btn_send": "Abrir WhatsApp"
   },
   "parceiro": {
+    "type": {
+      "partner": "Socio",
+      "agreement": "Convenio"
+    },
     "category": "Categoría",
     "company_name": "Nombre de la Empresa",
     "responsible": "Responsable",

+ 11 - 1
src/i18n/locales/pt.json

@@ -622,10 +622,16 @@
   },
   "dashboard": {
     "currency_format": "R$ {value}",
+    "filters": {
+      "search_associados": "Buscar por nome, e-mail, CPF ou crachá",
+      "search_parceiros": "Buscar por empresa, responsável ou CNPJ",
+      "search_dependentes": "Buscar por dependente, associado ou data",
+      "search_acessos": "Buscar por usuário, tipo ou data"
+    },
     "stats": {
       "total_associados": "Total Associados",
       "associados_ativos": "Associados Ativos",
-      "parceiros": "Parceiros",
+      "parceiros": "Parceiros / Convênios",
       "contratos_a_vencer": "Contratos a Vencer",
       "novos_mes": "Novos (mês)",
       "associados_pendentes": "Associados Pendentes",
@@ -704,6 +710,10 @@
     "whatsapp_btn_send": "Abrir WhatsApp"
   },
   "parceiro": {
+    "type": {
+      "partner": "Parceiro",
+      "agreement": "Convênio"
+    },
     "category": "Categoria",
     "company_name": "Nome da Empresa",
     "responsible": "Responsável",

+ 84 - 102
src/pages/dashboard/DashboardPage.vue

@@ -16,7 +16,7 @@
           >
             <q-card
               flat
-              class="stat-card cursor-pointer bg-white"
+              class="stat-card cursor-pointer bg-white full-height"
               :class="{ 'stat-card--active': activeCard === card.key }"
               @click="onCardClick(card)"
             >
@@ -25,7 +25,7 @@
                 <div class="text-h5 text-weight-bold q-mt-xs text-dark">
                   {{ stats[card.key] ?? '—' }}
                 </div>
-                <div class="text-caption text-grey-7 ellipsis">
+                <div class="text-grey-7 stat-card__label">
                   {{ $t(card.labelKey) }}
                 </div>
               </q-card-section>
@@ -33,18 +33,26 @@
           </div>
         </div>
 
-        <q-card v-if="activeCard" flat class="bg-white" style="border-radius: 12px">
+        <q-card v-if="activeCardConfig" flat class="bg-white" style="border-radius: 12px">
           <DefaultTableServerSide
             ref="tableRef"
             :key="activeCard"
-            :columns="tableColumns"
+            :columns="activeCardConfig.columns"
             :api-call="activeApiCall"
+            :extra-filters="extraFilters"
+            :search-placeholder="$t(activeCardConfig.searchPlaceholderKey)"
             :add-item="false"
-            sem-table-top
-            :open-item="['dependentes_pendentes', 'associados_pendentes'].includes(activeCard)"
-            :show-search-field="!['ultimos_acessos', 'associados_com_acesso_app'].includes(activeCard)"
+            :open-item="!!activeCardConfig.approval"
             @on-row-click="onRowClick"
           >
+            <template #filters>
+              <DashboardFilters
+                v-model="filters"
+                :card="activeCardConfig"
+                :status-options="statusOptions"
+              />
+            </template>
+
             <template #body-cell-status="{ row }">
               <q-td>
                 <q-badge
@@ -56,6 +64,16 @@
               </q-td>
             </template>
 
+            <template #body-cell-type="{ row }">
+              <q-td class="text-center">
+                <q-badge
+                  outline
+                  :color="row.type === 'agreement' ? 'teal' : 'violet-normal'"
+                  :label="row.type === 'agreement' ? $t('parceiro.type.agreement') : $t('parceiro.type.partner')"
+                />
+              </q-td>
+            </template>
+
             <template #body-cell-user_type="{ row }">
               <q-td class="text-center">
                 <q-badge
@@ -85,14 +103,12 @@ import { useQuasar } from "quasar";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
 import { getDashboardStats } from "src/api/dashboard";
-import { getUsersPaginated } from "src/api/user";
-import { getDependentsPaginated } from "src/api/profile";
-import { getPartnerAgreementsPaginated, getExpiringPartnerAgreementsPaginated } from "src/api/partnerAgreement";
-import { getAccessLogsPaginated } from "src/api/userAccessLog";
-import { formatDateYMDtoDMY, getStatusColor, getStatusI18nKey } from "src/helpers/utils";
+import { getStatusColor, getStatusI18nKey } from "src/helpers/utils";
 import { permissionStore } from "src/stores/permission";
+import DashboardFilters from "./components/DashboardFilters.vue";
 import DependentApprovalDialog from "./components/DependentApprovalDialog.vue";
 import AssociadoApprovalDialog from "./components/AssociadoApprovalDialog.vue";
+import { buildDashboardCards, formatDateTime } from "./dashboardCards";
 
 const { t } = useI18n();
 const $q = useQuasar();
@@ -103,110 +119,67 @@ const statsLoading = ref(true);
 const stats = ref({});
 const activeCard = 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" },
-  { key: "dependentes_pendentes", icon: "mdi-account-child-circle", labelKey: "dashboard.stats.dependentes_pendentes" },
-  { key: "ultimos_acessos",      icon: "mdi-login-variant",  labelKey: "dashboard.stats.ultimos_acessos" },
-  { key: "associados_com_acesso_app", icon: "mdi-cellphone-check", labelKey: "dashboard.stats.total_acessos_app" },
-];
-
-// Colunas por tipo de card
-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 columnsDependentes = computed(() => [
-  { name: "responsible_user_name", label: t("associado.associado"), field: (row) => row.responsible_user?.name ?? "—", align: "left", sortable: false },
-  { name: "name",       label: t("associado.dependent"),                  field: "name",       align: "left", sortable: false },
-  { name: "created_at", label: t("associado.dependent_registered_at"),    field: (row) => formatDateTime(row.created_at), align: "left", sortable: false },
-]);
-
-const columnsAccessLogs = computed(() => [
-  { name: "user_name",   label: t("access_log.user"),        field: "user_name",   align: "left",   sortable: false },
-  { name: "user_type",   label: t("access_log.user_type"),   field: "user_type",   align: "center", sortable: false },
-  { name: "accessed_at", label: t("access_log.accessed_at"), field: "accessed_at", align: "left",   sortable: false },
-]);
-
-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;
-  if (activeCard.value === "ultimos_acessos" || activeCard.value === "associados_com_acesso_app") return columnsAccessLogs.value;
-  if (activeCard.value === "dependentes_pendentes") return columnsDependentes.value;
-  return columnsAssociados.value;
-});
+// Filtros do bloco aberto e status disponíveis (mapeados pelo backend)
+const filters = ref({});
+const statusOptions = ref([]);
+
+const statCards = computed(() => buildDashboardCards(t));
+
+const activeCardConfig = computed(
+  () => statCards.value.find((card) => card.key === activeCard.value) ?? null,
+);
 
-// Função de API correspondente a cada card — nova referência a cada computed
+const extraFilters = computed(() => ({ ...filters.value }));
+
+// Os status possíveis são pedidos apenas na primeira busca de cada bloco
 const activeApiCall = computed(() => {
-  switch (activeCard.value) {
-    case "total_associados":
-      return (p) => getUsersPaginated({ ...p, type: "associado" });
-    case "associados_ativos":
-      return (p) => getUsersPaginated({ ...p, type: "associado", status: "active" });
-    case "associados_pendentes":
-      return (p) => getUsersPaginated({ ...p, type: "associado", status: "pending" });
-    case "dependentes_pendentes":
-      return (p) => getDependentsPaginated({ ...p, status: "pending" });
-    case "parceiros":
-      return (p) => getPartnerAgreementsPaginated({ ...p });
-    case "contratos_a_vencer":
-      return (p) => getExpiringPartnerAgreementsPaginated({ ...p });
-    case "novos_mes":
-      return (p) => getPartnerAgreementsPaginated({ ...p, createdMonth: "current" });
-    case "ultimos_acessos":
-      return (p) => getAccessLogsPaginated({ ...p, last_per_user: true });
-    case "associados_com_acesso_app":
-      return (p) => getAccessLogsPaginated({ ...p, type: "associado", last_per_user: true });
-    default:
-      return null;
-  }
-});
+  const card = activeCardConfig.value;
+  if (!card) return null;
 
-const formatDateTime = (isoString) => {
-  if (!isoString) return "—";
-  const d = new Date(isoString.includes("T") ? isoString : isoString.replace(" ", "T"));
-  if (Number.isNaN(d.getTime())) return isoString;
-  return d.toLocaleString("pt-BR", {
-    day: "2-digit", month: "2-digit", year: "numeric",
-    hour: "2-digit", minute: "2-digit", second: "2-digit",
-  });
-};
+  let needsStatusOptions = card.filters.includes("status");
+
+  return async (params) => {
+    const response = await card.request({
+      ...params,
+      withStatusOptions: needsStatusOptions || undefined,
+    });
+
+    const statuses = response.data.result.statuses;
+    if (statuses) {
+      statusOptions.value = statuses;
+      needsStatusOptions = false;
+    }
+
+    return response;
+  };
+});
 
 const onCardClick = (card) => {
-  activeCard.value = activeCard.value === card.key ? null : card.key;
+  if (activeCard.value === card.key) {
+    activeCard.value = null;
+    return;
+  }
+
+  activeCard.value = card.key;
+  filters.value = { ...card.defaultFilters };
+  statusOptions.value = [];
 };
 
+const rowStatus = (row) => (typeof row.status === "object" ? row.status?.value : row.status);
+
 const onRowClick = ({ row }) => {
-  if (activeCard.value === "dependentes_pendentes") {
+  const approval = activeCardConfig.value?.approval;
+
+  if (approval === "dependente") {
     onDependentRowClick(row);
-  } else if (activeCard.value === "associados_pendentes") {
+  } else if (approval === "associado") {
     onAssociadoRowClick(row);
   }
 };
 
 const onDependentRowClick = (row) => {
+  if (rowStatus(row) !== "pending") return;
+
   if (!permission_store.getAccess("associado.dependente_aprovacao", "edit")) {
     $q.notify({ type: "negative", message: t("validation.permissions.edit") });
     return;
@@ -221,6 +194,8 @@ const onDependentRowClick = (row) => {
 };
 
 const onAssociadoRowClick = (row) => {
+  if (rowStatus(row) !== "pending") return;
+
   if (!permission_store.getAccess("associado", "edit")) {
     $q.notify({ type: "negative", message: t("validation.permissions.edit") });
     return;
@@ -250,6 +225,13 @@ onMounted(async () => {
   transition: box-shadow 0.2s, border-color 0.2s, background-color 0.2s;
 }
 
+.stat-card__label {
+  font-size: 0.7rem;
+  line-height: 1.25;
+  white-space: normal;
+  word-break: break-word;
+}
+
 .stat-card:hover {
   box-shadow: 0 2px 8px rgba(102, 29, 117, 0.15);
 }

+ 103 - 0
src/pages/dashboard/components/DashboardFilters.vue

@@ -0,0 +1,103 @@
+<template>
+  <DefaultSelect
+    v-if="hasStatusFilter"
+    :model-value="filters.status ?? null"
+    :options="statusSelectOptions"
+    :label="$t('common.terms.status')"
+    emit-value
+    map-options
+    clearable
+    dense
+    outlined
+    color="violet-normal"
+    class="col-xs-12 col-sm-6 col-md-auto"
+    style="min-width: 180px"
+    @update:model-value="onUpdate('status', $event)"
+  />
+
+  <DefaultSelect
+    v-if="hasTypeFilter"
+    :model-value="filters.type ?? null"
+    :options="typeOptions"
+    :label="$t('common.ui.misc.type')"
+    emit-value
+    map-options
+    clearable
+    dense
+    outlined
+    color="violet-normal"
+    class="col-xs-12 col-sm-6 col-md-auto"
+    style="min-width: 180px"
+    @update:model-value="onUpdate('type', $event)"
+  />
+
+  <DefaultSelect
+    v-if="hasUserTypeFilter"
+    :model-value="filters.user_type ?? null"
+    :options="userTypeOptions"
+    :label="$t('access_log.user_type')"
+    emit-value
+    map-options
+    clearable
+    dense
+    outlined
+    color="violet-normal"
+    class="col-xs-12 col-sm-6 col-md-auto"
+    style="min-width: 180px"
+    @update:model-value="onUpdate('user_type', $event)"
+  />
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import { getStatusI18nKey } from "src/helpers/utils";
+
+const { card, statusOptions } = defineProps({
+  card: {
+    type: Object,
+    required: true,
+  },
+  statusOptions: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const filters = defineModel({
+  type: Object,
+  default: () => ({}),
+});
+
+const { t } = useI18n();
+
+const hasStatusFilter = computed(
+  () => card.filters.includes("status") && statusOptions.length > 0,
+);
+
+const hasTypeFilter = computed(() => card.filters.includes("type"));
+
+const hasUserTypeFilter = computed(() => card.filters.includes("user_type"));
+
+const statusSelectOptions = computed(() =>
+  statusOptions.map((status) => ({
+    label: t(getStatusI18nKey(status)),
+    value: status,
+  })),
+);
+
+const typeOptions = computed(() => [
+  { label: t("parceiro.type.partner"), value: "partner" },
+  { label: t("parceiro.type.agreement"), value: "agreement" },
+]);
+
+const userTypeOptions = computed(() => [
+  { label: t("auth.type.associado"), value: "associado" },
+  { label: t("auth.type.parceiro"), value: "parceiro" },
+]);
+
+const onUpdate = (field, value) => {
+  filters.value = { ...filters.value, [field]: value ?? undefined };
+};
+</script>

+ 166 - 0
src/pages/dashboard/dashboardCards.js

@@ -0,0 +1,166 @@
+import { getUsersPaginated } from "src/api/user";
+import { getDependentsPaginated } from "src/api/profile";
+import { getPartnerAgreementsPaginated } from "src/api/partnerAgreement";
+import { getAccessLogsPaginated } from "src/api/userAccessLog";
+import { formatDateYMDtoDMY } from "src/helpers/utils";
+
+/**
+ * @description Formata uma data ISO (ou "YYYY-MM-DD HH:mm:ss") para o padrão brasileiro.
+ * @param {string} isoString data.
+ * @returns {string} data formatada ou "—" quando vazia.
+ */
+export const formatDateTime = (isoString) => {
+  if (!isoString) return "—";
+  const d = new Date(isoString.includes("T") ? isoString : isoString.replace(" ", "T"));
+  if (Number.isNaN(d.getTime())) return isoString;
+  return d.toLocaleString("pt-BR", {
+    day: "2-digit", month: "2-digit", year: "numeric",
+    hour: "2-digit", minute: "2-digit", second: "2-digit",
+  });
+};
+
+/**
+ * @description Configuração dos cards da dashboard: colunas, filtros e chamada de API
+ * de cada bloco. `filters` define quais campos o componente de filtro renderiza e
+ * `defaultFilters` os valores pré-selecionados (alteráveis pelo usuário).
+ * @param {Function} t função de tradução (vue-i18n).
+ * @returns {Array<object>} configuração dos cards.
+ */
+export const buildDashboardCards = (t) => {
+  const columnsAssociados = [
+    { 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 buildColumnsParceiros = ({ withType = false } = {}) => [
+    { 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 },
+    ...(withType
+      ? [{ name: "type", label: t("common.ui.misc.type"), field: "type", align: "center", sortable: false }]
+      : []),
+    { 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 columnsParceiros = buildColumnsParceiros();
+
+  const columnsContratosAVencer = [
+    { 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 columnsDependentes = [
+    { name: "responsible_user_name", label: t("associado.associado"),                field: (row) => row.responsible_user?.name ?? "—", align: "left", sortable: false },
+    { name: "name",                  label: t("associado.dependent"),                field: "name", align: "left", sortable: false },
+    { name: "created_at",            label: t("associado.dependent_registered_at"),  field: (row) => formatDateTime(row.created_at), align: "left", sortable: false },
+  ];
+
+  const columnsAccessLogs = [
+    { name: "user_name",   label: t("access_log.user"),        field: "user_name",   align: "left",   sortable: false },
+    { name: "user_type",   label: t("access_log.user_type"),   field: "user_type",   align: "center", sortable: false },
+    { name: "accessed_at", label: t("access_log.accessed_at"), field: "accessed_at", align: "left",   sortable: false },
+  ];
+
+  const associadosRequest = (params) => getUsersPaginated({ ...params, type: "associado" });
+  const accessLogsRequest = (params) =>
+    getAccessLogsPaginated({ ...params, type: params.user_type, last_per_user: true });
+
+  return [
+    {
+      key: "total_associados",
+      icon: "mdi-account-group",
+      labelKey: "dashboard.stats.total_associados",
+      columns: columnsAssociados,
+      filters: ["status"],
+      defaultFilters: {},
+      searchPlaceholderKey: "dashboard.filters.search_associados",
+      request: associadosRequest,
+    },
+    {
+      key: "associados_ativos",
+      icon: "mdi-trending-up",
+      labelKey: "dashboard.stats.associados_ativos",
+      columns: columnsAssociados,
+      filters: ["status"],
+      defaultFilters: { status: "active" },
+      searchPlaceholderKey: "dashboard.filters.search_associados",
+      request: associadosRequest,
+    },
+    {
+      key: "parceiros",
+      icon: "mdi-handshake",
+      labelKey: "dashboard.stats.parceiros",
+      columns: buildColumnsParceiros({ withType: true }),
+      filters: ["status", "type"],
+      defaultFilters: {},
+      searchPlaceholderKey: "dashboard.filters.search_parceiros",
+      request: (params) => getPartnerAgreementsPaginated({ ...params }),
+    },
+    {
+      key: "contratos_a_vencer",
+      icon: "mdi-file-clock",
+      labelKey: "dashboard.stats.contratos_a_vencer",
+      columns: columnsContratosAVencer,
+      filters: ["status"],
+      defaultFilters: {},
+      searchPlaceholderKey: "dashboard.filters.search_parceiros",
+      request: (params) => getPartnerAgreementsPaginated({ ...params, expiresInDays: 30 }),
+    },
+    {
+      key: "novos_mes",
+      icon: "mdi-account-plus",
+      labelKey: "dashboard.stats.novos_mes",
+      columns: columnsParceiros,
+      filters: ["status"],
+      defaultFilters: {},
+      searchPlaceholderKey: "dashboard.filters.search_parceiros",
+      request: (params) => getPartnerAgreementsPaginated({ ...params, createdMonth: "current" }),
+    },
+    {
+      key: "associados_pendentes",
+      icon: "mdi-account-alert",
+      labelKey: "dashboard.stats.associados_pendentes",
+      columns: columnsAssociados,
+      filters: ["status"],
+      defaultFilters: { status: "pending" },
+      searchPlaceholderKey: "dashboard.filters.search_associados",
+      approval: "associado",
+      request: associadosRequest,
+    },
+    {
+      key: "dependentes_pendentes",
+      icon: "mdi-account-child-circle",
+      labelKey: "dashboard.stats.dependentes_pendentes",
+      columns: columnsDependentes,
+      filters: [],
+      defaultFilters: {},
+      searchPlaceholderKey: "dashboard.filters.search_dependentes",
+      approval: "dependente",
+      request: (params) => getDependentsPaginated({ ...params, status: "pending" }),
+    },
+    {
+      key: "ultimos_acessos",
+      icon: "mdi-login-variant",
+      labelKey: "dashboard.stats.ultimos_acessos",
+      columns: columnsAccessLogs,
+      filters: ["user_type"],
+      defaultFilters: {},
+      searchPlaceholderKey: "dashboard.filters.search_acessos",
+      request: accessLogsRequest,
+    },
+    {
+      key: "associados_com_acesso_app",
+      icon: "mdi-cellphone-check",
+      labelKey: "dashboard.stats.total_acessos_app",
+      columns: columnsAccessLogs,
+      filters: ["user_type"],
+      defaultFilters: { user_type: "associado" },
+      searchPlaceholderKey: "dashboard.filters.search_acessos",
+      request: accessLogsRequest,
+    },
+  ];
+};