Просмотр исходного кода

refactor: ajuste tabela despesas

Gustavo Mantovani 1 месяц назад
Родитель
Сommit
066eecec15

+ 12 - 73
src/helpers/buildMetricCards.js

@@ -7,85 +7,24 @@ import {
 
 export function buildMetricCards(summary, options = {}) {
   const { isAllPropertiesSelected = false, referenceLabel = "" } = options;
+
   const displayedExpenses =
     Number(summary.expenses_amount ?? 0) + Number(summary.ota_amount ?? 0);
 
-  // const totalExpenses = Number(summary.expenses ?? 0);
-
-  // const netBalance = Number(
-  //  summary.net_revenue ??
-  //    summary.net_balance_amount ??
-  //    Number(summary.reserve_total ?? 0) -
-  //      Number(summary.total_forward_fee_all ?? 0),
-  // );
-
-  // const grossRevenue = Number(summary.reserve_total ?? 0);
-  // const netRevenue = netBalance;
-  // const netPayout = Number(summary.final_payout_amount ?? 0);
+  const maintenanceExpenseAmount = Number(
+    summary.expenses_breakdown?.items
+      ?.filter((expense) => expense.source === "manual")
+      ?.filter((expense) => expense.category === "maintenance")
+      ?.reduce(
+        (total, expense) => total + Number(expense.final_amount ?? 0),
+        0,
+      ) ?? 0,
+  );
 
   const occupancyCaption = isAllPropertiesSelected
     ? "percentual do período"
     : `${formatInteger(summary.occupied_nights_in_month)} de ${formatInteger(summary.days_in_month)} dias`;
 
-  /*
-  return [
-    {
-      label: "Faturamento Bruto",
-      value: formatCurrency(grossRevenue),
-      caption: referenceLabel || "Mês selecionado",
-    },
-    {
-      label: "Faturamento Líquido",
-      value: formatCurrency(netRevenue),
-      caption: "Bruto - taxa OTA",
-    },
-    {
-      label: "Diária Média",
-      value: formatCurrency(summary.average_price_per_night),
-      caption: "Bruto ÷ diárias",
-    },
-    {
-      label: "Ticket Médio/Reserva",
-      value: formatCurrency(summary.average_reservation_ticket),
-      caption: isAllPropertiesSelected
-        ? "Todos apartamentos"
-        : "No imóvel selecionado",
-    },
-    {
-      label: "Repasse Total",
-      value: formatCurrency(netPayout),
-      caption: isAllPropertiesSelected
-        ? "Todos apartamentos"
-        : "No imóvel selecionado",
-    },
-    {
-      label: "Ocupação",
-      value: formatPercent(summary.occupancy_rate, 1),
-      caption: occupancyCaption,
-    },
-    {
-      label: "Total de Reservas",
-      value: formatInteger(summary.reservations_count),
-      caption: "no período",
-    },
-    {
-      label: "Dias por Reserva",
-      value: formatDecimal(summary.average_nights_per_reservation),
-      caption: "média de permanência",
-    },
-    {
-      label: "Limpeza",
-      value: formatInteger(summary.cleanings_count),
-      caption: "total no período",
-    },
-    {
-      label: "Despesas",
-      value: formatCurrency(totalExpenses),
-      caption: "manutenção e operação",
-    },
-  ];
-  */
-
   return [
     // INDICADORES
 
@@ -121,8 +60,8 @@ export function buildMetricCards(summary, options = {}) {
 
     {
       label: "Manutenção",
-      value: formatInteger(summary.maintenance_days),
-      caption: "dias bloqueados",
+      value: formatCurrency(maintenanceExpenseAmount),
+      caption: "despesas manuais",
     },
 
     {

+ 564 - 0
src/pages/dashboard/DashboardExpensesPage.vue

@@ -0,0 +1,564 @@
+<template>
+  <q-page class="owner-dashboard-page">
+    <DefaultHeaderPage />
+
+    <div class="dashboard-shell">
+      <q-banner
+        v-if="!hasProperties"
+        class="bg-surface text-text radius-8"
+        rounded
+      >
+        Nenhum imóvel vinculado ao proprietário autenticado.
+      </q-banner>
+
+      <template v-else>
+        <DashboardFiltersBar
+          action-label="Voltar ao dashboard"
+          title="Despesas"
+          :loading="loading"
+          :month-options="monthOptions"
+          :property-options="propertyOptions"
+          :selected-month="selectedMonth"
+          :selected-property-option="selectedPropertyOption"
+          :selected-year="selectedYear"
+          :show-export="false"
+          :year-options="yearOptions"
+          @action="goToDashboard"
+          @update:month="handleMonthChange"
+          @update:property="handlePropertyChange"
+          @update:year="handleYearChange"
+        />
+
+        <q-separator class="dashboard-separator" />
+
+        <div class="section-header">
+          <span class="dashboard-section-caption">Indicadores</span>
+        </div>
+
+        <div class="metrics-grid">
+          <DashboardMetricCard
+            v-for="card in firstRowCards"
+            :key="card.label"
+            :caption="card.caption"
+            :label="card.label"
+            :value="card.value"
+            variant="primary"
+          />
+        </div>
+
+        <div class="section-header">
+          <span class="dashboard-section-caption">Financeiro</span>
+        </div>
+
+        <div class="metrics-grid">
+          <DashboardMetricCard
+            v-for="card in secondRowCards"
+            :key="card.label"
+            :caption="card.caption"
+            :label="card.label"
+            :value="card.value"
+            variant="secondary"
+          />
+        </div>
+
+        <q-card flat class="panel-card panel-card--soft expenses-panel">
+          <div class="panel-title">Despesas do Período</div>
+
+          <DashboardPayoutTable
+            :columns="expenseTableColumns"
+            empty-label="Nenhuma despesa encontrada para o período."
+            row-key="key"
+            :rows="expenseTableRows"
+            total-label="Total de despesas"
+            :total-row="expenseTableTotalRow"
+          />
+        </q-card>
+      </template>
+
+      <q-inner-loading :showing="loading">
+        <q-spinner color="primary" size="48px" />
+      </q-inner-loading>
+    </div>
+  </q-page>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useQuasar } from "quasar";
+import { useRoute, useRouter } from "vue-router";
+
+import { getOwnerDashboard } from "src/api/ownerDashboard";
+import { buildMetricCards as buildDashboardMetricCards } from "src/helpers/buildMetricCards";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DashboardFiltersBar from "./components/DashboardFiltersBar.vue";
+import DashboardMetricCard from "./components/DashboardMetricCard.vue";
+import DashboardPayoutTable from "./components/DashboardPayoutTable.vue";
+
+const $q = useQuasar();
+const route = useRoute();
+const router = useRouter();
+
+const dashboard = ref(null);
+const loading = ref(false);
+const selectedPropertyOption = ref(null);
+const selectedMonth = ref(null);
+const selectedYear = ref(null);
+const dashboardRequestId = ref(0);
+
+const defaultSummary = Object.freeze({
+  reserve_total: 0,
+  total_forward_fee_all: 0,
+  owner_payout_amount: 0,
+  final_payout_amount: 0,
+  total_expenses_amount: 0,
+  occupied_nights_in_month: 0,
+  occupancy_rate: 0,
+  average_price_per_night: null,
+  average_reservation_ticket: null,
+  average_nights_per_reservation: 0,
+  maintenance_days: 0,
+  cleanings_count: 0,
+  expenses: 0,
+  reservations_count: 0,
+  distinct_reservations_count: 0,
+  checkout_reservations_count: 0,
+  crossed_reservations_count: 0,
+  repeated_reservations_count: 0,
+  crossed_cleanings_count: 0,
+  repeated_cleanings_count: 0,
+  available_days: 0,
+  days_in_month: 30,
+  properties_count: 1,
+
+  gross_revenue: 0,
+  ota_amount: 0,
+  net_revenue_amount: 0,
+  cleaning_total_amount: 0,
+  management_fee_amount: 0,
+  expenses_amount: 0,
+  payout_amount: 0,
+  expenses_breakdown: {
+    items: [],
+    totals: {
+      expenses_amount: 0,
+    },
+  },
+});
+
+const monthLabels = [
+  "Janeiro",
+  "Fevereiro",
+  "Março",
+  "Abril",
+  "Maio",
+  "Junho",
+  "Julho",
+  "Agosto",
+  "Setembro",
+  "Outubro",
+  "Novembro",
+  "Dezembro",
+];
+
+const availableReferences = computed(
+  () => dashboard.value?.filters?.available_references ?? [],
+);
+
+const selectedFilters = computed(
+  () => dashboard.value?.filters?.selected ?? {},
+);
+
+const propertyOptions = computed(() => {
+  const properties = dashboard.value?.filters?.properties ?? [];
+
+  return [
+    { id: null, label: "Todos os imóveis" },
+    ...properties.map((property) => ({
+      id: property.id,
+      label: property.label,
+    })),
+  ];
+});
+
+const hasProperties = computed(() => propertyOptions.value.length > 1);
+
+const selectedPropertyId = computed(() => {
+  return selectedPropertyOption.value?.id ?? null;
+});
+
+const isAllPropertiesSelected = computed(
+  () => selectedPropertyId.value === null,
+);
+
+const summary = computed(() => dashboard.value?.summary ?? defaultSummary);
+
+const totalCapacityDays = computed(() =>
+  Math.max(
+    1,
+    Number(summary.value.days_in_month ?? 0) *
+      Number(summary.value.properties_count ?? 1),
+  ),
+);
+
+const yearOptions = computed(() => {
+  const years = [
+    ...new Set(
+      availableReferences.value.map((reference) => reference.reference_year),
+    ),
+  ];
+
+  return years
+    .sort((a, b) => b - a)
+    .map((year) => ({
+      label: String(year),
+      value: year,
+    }));
+});
+
+const monthOptions = computed(() => {
+  if (!selectedYear.value) {
+    return [];
+  }
+
+  const months = [
+    ...new Set(
+      availableReferences.value
+        .filter((reference) => reference.reference_year === selectedYear.value)
+        .map((reference) => reference.reference_month),
+    ),
+  ];
+
+  return months
+    .sort((a, b) => a - b)
+    .map((month) => ({
+      label: monthLabels[month - 1] ?? String(month),
+      value: month,
+    }));
+});
+
+const selectedReferenceLabel = computed(() => {
+  if (!selectedMonth.value || !selectedYear.value) {
+    return "Mês selecionado";
+  }
+
+  const monthLabel =
+    monthLabels[selectedMonth.value - 1] ?? String(selectedMonth.value);
+
+  return `${monthLabel} ${selectedYear.value}`;
+});
+
+const allMetricCards = computed(() =>
+  buildDashboardMetricCards(summary.value, {
+    isAllPropertiesSelected: isAllPropertiesSelected.value,
+    referenceLabel: selectedReferenceLabel.value,
+    totalCapacityDays: totalCapacityDays.value,
+  }),
+);
+
+const firstRowCards = computed(() => allMetricCards.value.slice(0, 7));
+
+const secondRowCards = computed(() => allMetricCards.value.slice(7, 14));
+
+const expenseRows = computed(
+  () => summary.value?.expenses_breakdown?.items ?? [],
+);
+
+const expenseTotal = computed(
+  () => summary.value?.expenses_breakdown?.totals?.expenses_amount ?? 0,
+);
+
+const expenseTableColumns = [
+  {
+    key: "label",
+    label: "Despesa",
+    type: "text",
+    direction: null,
+  },
+  {
+    key: "source_label",
+    label: "Origem",
+    type: "text",
+    direction: null,
+  },
+  {
+    key: "final_amount",
+    label: "Valor",
+    type: "currency",
+    direction: null,
+  },
+];
+
+const expenseTableRows = computed(() =>
+  expenseRows.value.map((expense, index) => ({
+    ...expense,
+    key: expense.key || `${expense.label}-${index}`,
+    source_label: expense.source === "manual" ? "Interna" : "Stays/Sistema",
+  })),
+);
+
+const expenseTableTotalRow = computed(() => ({
+  source_label: "",
+  final_amount: expenseTotal.value,
+}));
+
+const buildDashboardParams = ({ year, month, propertyId } = {}) => {
+  const params = {};
+
+  if (year) {
+    params.year = year;
+  }
+
+  if (month) {
+    params.month = month;
+  }
+
+  if (propertyId !== null && propertyId !== undefined) {
+    params.property_id = propertyId;
+  }
+
+  return params;
+};
+
+const updateRouteQuery = () => {
+  router.replace({
+    name: "DashboardExpensesPage",
+    query: {
+      year: selectedYear.value ?? undefined,
+      month: selectedMonth.value ?? undefined,
+      property_id: selectedPropertyId.value ?? undefined,
+    },
+  });
+};
+
+const findPropertyOptionById = (propertyId) => {
+  return (
+    propertyOptions.value.find((option) => option.id === propertyId) ??
+    propertyOptions.value[0] ??
+    null
+  );
+};
+
+const fetchDashboard = async ({ year, month, propertyId } = {}) => {
+  const requestId = ++dashboardRequestId.value;
+
+  loading.value = true;
+
+  try {
+    const payload = await getOwnerDashboard(
+      buildDashboardParams({ year, month, propertyId }),
+    );
+
+    if (requestId !== dashboardRequestId.value) {
+      return;
+    }
+
+    dashboard.value = payload;
+
+    selectedPropertyOption.value = findPropertyOptionById(
+      payload.filters.selected?.property_id ?? null,
+    );
+    selectedMonth.value = payload.filters.selected?.reference_month ?? null;
+    selectedYear.value = payload.filters.selected?.reference_year ?? null;
+
+    updateRouteQuery();
+  } catch (error) {
+    if (requestId !== dashboardRequestId.value) {
+      return;
+    }
+
+    $q.notify({
+      type: "negative",
+      message:
+        error?.response?.data?.message ??
+        "Não foi possível carregar as despesas do proprietário.",
+    });
+  } finally {
+    if (requestId === dashboardRequestId.value) {
+      loading.value = false;
+    }
+  }
+};
+
+const handleMonthChange = async (month) => {
+  if (month === selectedFilters.value.reference_month) {
+    return;
+  }
+
+  await fetchDashboard({
+    year: selectedYear.value,
+    month,
+    propertyId: selectedPropertyId.value,
+  });
+};
+
+const handleYearChange = async (year) => {
+  const availableMonth =
+    availableReferences.value
+      .filter((reference) => reference.reference_year === year)
+      .sort((a, b) => b.reference_month - a.reference_month)[0]
+      ?.reference_month ?? selectedMonth.value;
+
+  if (
+    year === selectedFilters.value.reference_year &&
+    availableMonth === selectedFilters.value.reference_month
+  ) {
+    return;
+  }
+
+  await fetchDashboard({
+    year,
+    month: availableMonth,
+    propertyId: selectedPropertyId.value,
+  });
+};
+
+const handlePropertyChange = async (propertyOption) => {
+  const nextPropertyId = propertyOption?.id ?? null;
+  const currentPropertyId = selectedFilters.value.property_id ?? null;
+
+  if (nextPropertyId === currentPropertyId) {
+    return;
+  }
+
+  await fetchDashboard({
+    year: selectedYear.value,
+    month: selectedMonth.value,
+    propertyId: nextPropertyId,
+  });
+};
+
+const goToDashboard = () => {
+  router.push({
+    name: "DashboardPage",
+    query: {
+      year: selectedYear.value ?? undefined,
+      month: selectedMonth.value ?? undefined,
+      property_id: selectedPropertyId.value ?? undefined,
+    },
+  });
+};
+
+const resolveInitialNumber = (value) => {
+  const numberValue = Number(value);
+
+  return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : null;
+};
+
+onMounted(async () => {
+  await fetchDashboard({
+    year: resolveInitialNumber(route.query.year),
+    month: resolveInitialNumber(route.query.month),
+    propertyId: resolveInitialNumber(route.query.property_id),
+  });
+});
+</script>
+
+<style scoped lang="scss">
+.owner-dashboard-page {
+  position: relative;
+}
+
+.dashboard-shell {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+  min-width: 0;
+}
+
+.dashboard-separator {
+  margin-top: -4px;
+  background: rgba(8, 81, 76, 0.12);
+}
+
+.dashboard-section-caption {
+  margin-top: -2px;
+  margin-bottom: -4px;
+  color: #08514c;
+  font-size: 19px;
+  font-weight: 400;
+  line-height: 1.1;
+  vertical-align: middle;
+}
+
+.section-header {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.metrics-grid {
+  display: grid;
+  grid-template-columns: repeat(7, minmax(0, 1fr));
+  gap: 10px;
+  min-width: 0;
+  align-items: stretch;
+}
+
+.panel-card {
+  padding: 18px;
+  border-radius: 14px;
+  background: #ffffff;
+  border: 1px solid #d9e3e7;
+  min-height: 300px;
+  min-width: 0;
+  vertical-align: middle;
+}
+
+.panel-card--soft {
+  background: #f0f3f5;
+}
+
+.expenses-panel {
+  width: 50%;
+  align-self: flex-start;
+}
+
+.panel-title {
+  margin-bottom: 16px;
+  font-size: 19px;
+  font-weight: 400;
+  color: #08514c;
+}
+
+.owner-dashboard-page :deep(.text-h6.text-bold) {
+  display: none;
+}
+
+@media (max-width: 1650px) {
+  .metrics-grid {
+    grid-template-columns: repeat(7, minmax(0, 1fr));
+    gap: 8px;
+  }
+}
+
+@media (max-width: 1450px) {
+  .metrics-grid {
+    grid-template-columns: repeat(5, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 1100px) {
+  .metrics-grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 768px) {
+  .metrics-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 640px) {
+  .metrics-grid {
+    grid-template-columns: 1fr;
+    gap: 10px;
+  }
+
+  .expenses-panel {
+    width: 100%;
+  }
+}
+</style>

+ 37 - 7
src/pages/dashboard/DashboardPage.vue

@@ -13,6 +13,7 @@
 
       <template v-else>
         <DashboardFiltersBar
+          action-label="Ver despesas"
           :can-export-report="canExportReport"
           :exporting="exporting"
           :loading="loading"
@@ -22,6 +23,7 @@
           :selected-property-option="selectedPropertyOption"
           :selected-year="selectedYear"
           :year-options="yearOptions"
+          @action="goToExpenses"
           @export="exportDashboardReport"
           @update:month="handleMonthChange"
           @update:property="handlePropertyChange"
@@ -30,12 +32,6 @@
 
         <q-separator class="dashboard-separator" />
 
-        <!--
-        <div class="dashboard-section-caption">
-          Faturamento mês atual, Ocupação e Reservar
-        </div>
-        !-->
-
         <div class="section-header">
           <span class="dashboard-section-caption">
             Indicadores
@@ -132,6 +128,7 @@ import {
 
 import { computed, onMounted, ref } from "vue";
 import { useQuasar } from "quasar";
+import { useRoute, useRouter } from "vue-router";
 
 import {
   downloadOwnerDashboardReport,
@@ -166,6 +163,8 @@ ChartJS.register(
 );
 
 const $q = useQuasar();
+const route = useRoute();
+const router = useRouter();
 
 const dashboard = ref(null);
 const loading = ref(false);
@@ -209,6 +208,12 @@ const defaultSummary = Object.freeze({
   management_fee_amount: 0,
   expenses_amount: 0,
   payout_amount: 0,
+  expenses_breakdown: {
+    items: [],
+    totals: {
+      expenses_amount: 0,
+    },
+  },
 });
 
 const monthLabels = [
@@ -796,10 +801,31 @@ const handlePropertyChange = async (propertyOption) => {
   });
 };
 
+const goToExpenses = () => {
+  router.push({
+    name: "DashboardExpensesPage",
+    query: {
+      year: selectedYear.value ?? undefined,
+      month: selectedMonth.value ?? undefined,
+      property_id: selectedPropertyId.value ?? undefined,
+    },
+  });
+};
+
+const resolveInitialNumber = (value) => {
+  const numberValue = Number(value);
+
+  return Number.isFinite(numberValue) && numberValue > 0 ? numberValue : null;
+};
+
 //
 
 onMounted(async () => {
-  await fetchDashboard();
+  await fetchDashboard({
+    year: resolveInitialNumber(route.query.year),
+    month: resolveInitialNumber(route.query.month),
+    propertyId: resolveInitialNumber(route.query.property_id),
+  });
 });
 </script>
 
@@ -902,6 +928,10 @@ onMounted(async () => {
   .metrics-grid {
     grid-template-columns: repeat(3, minmax(0, 1fr));
   }
+
+  .dashboard-panels {
+    grid-template-columns: 1fr;
+  }
 }
 
 @media (max-width: 768px) {

+ 33 - 2
src/pages/dashboard/components/DashboardFiltersBar.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="dashboard-header">
-    <div class="dashboard-title">Dashboard</div>
+    <div class="dashboard-title">{{ title }}</div>
 
     <div class="dashboard-filters">
       <q-select
@@ -47,6 +47,17 @@
       />
 
       <q-btn
+        v-if="actionLabel"
+        class="dashboard-action-btn"
+        color="primary"
+        no-caps
+        outline
+        :label="actionLabel"
+        @click="$emit('action')"
+      />
+
+      <q-btn
+        v-if="showExport"
         class="dashboard-export-btn"
         color="primary"
         label="Exportar PDF"
@@ -74,6 +85,10 @@ defineProps({
     type: Boolean,
     default: false,
   },
+  actionLabel: {
+    type: String,
+    default: "",
+  },
   monthOptions: {
     type: Array,
     default: () => [],
@@ -94,13 +109,27 @@ defineProps({
     type: Number,
     default: null,
   },
+  showExport: {
+    type: Boolean,
+    default: true,
+  },
+  title: {
+    type: String,
+    default: "Dashboard",
+  },
   yearOptions: {
     type: Array,
     default: () => [],
   },
 });
 
-defineEmits(["export", "update:month", "update:property", "update:year"]);
+defineEmits([
+  "action",
+  "export",
+  "update:month",
+  "update:property",
+  "update:year",
+]);
 </script>
 
 <style scoped lang="scss">
@@ -146,6 +175,7 @@ defineEmits(["export", "update:month", "update:property", "update:year"]);
   min-width: 110px;
 }
 
+.dashboard-action-btn,
 .dashboard-export-btn {
   height: 40px;
   vertical-align: middle;
@@ -174,6 +204,7 @@ defineEmits(["export", "update:month", "update:property", "update:year"]);
     min-width: 100%;
   }
 
+  .dashboard-action-btn,
   .dashboard-export-btn {
     width: 100%;
     max-width: 100%;

+ 110 - 36
src/pages/dashboard/components/DashboardPayoutTable.vue

@@ -3,14 +3,14 @@
     <table class="payout-table">
       <thead>
         <tr>
-          <th v-for="column in columns" :key="column.key">
+          <th v-for="column in resolvedColumns" :key="column.key">
             <div class="header-wrapper">
               <div class="header-content">
                 <q-icon
                   class="cursor-pointer sort-icon q-my-md"
-                  :name="column.direction === 'desc' ? 'south' : 'north'"
                   size="15px"
                   :color="column.direction ? 'primary' : '#b0b0b0'"
+                  :name="column.direction === 'desc' ? 'south' : 'north'"
                   @click="toggleSort(column)"
                 />
 
@@ -24,31 +24,23 @@
       </thead>
 
       <tbody>
-        <tr v-for="item in sortedRows" :key="item.property_id">
-          <td v-for="column in columns" :key="column.key">
-            <template v-if="column.key === 'final_payout_amount'">
-              {{ formatCurrency(item[column.key]) }}
-            </template>
-
-            <template v-else-if="column.key === 'reservations_count'">
-              {{ formatInteger(resolveReservationsCount(item)) }}
-            </template>
-
-            <template v-else>
-              {{ item[column.key] }}
-            </template>
+        <tr v-for="item in sortedRows" :key="resolveRowKey(item)">
+          <td v-for="column in resolvedColumns" :key="column.key">
+            {{ formatCell(resolveColumnValue(item, column), column) }}
           </td>
         </tr>
 
-        <tr class="payout-total-row">
-          <td>Total</td>
-
-          <td>
-            {{ formatInteger(totalReservations) }}
+        <tr v-if="!sortedRows.length">
+          <td class="payout-empty-row" :colspan="resolvedColumns.length">
+            {{ emptyLabel }}
           </td>
+        </tr>
+
+        <tr class="payout-total-row">
+          <td>{{ totalLabel }}</td>
 
-          <td>
-            {{ formatCurrency(totalValue) }}
+          <td v-for="column in totalColumns" :key="column.key">
+            {{ formatCell(resolvedTotalRow[column.key], column) }}
           </td>
         </tr>
       </tbody>
@@ -57,14 +49,39 @@
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
+import { computed, ref, watch } from "vue";
 
 const props = defineProps({
+  columns: {
+    type: Array,
+    default: null,
+  },
+
+  emptyLabel: {
+    type: String,
+    default: "Nenhum registro encontrado para o período.",
+  },
+
+  rowKey: {
+    type: String,
+    default: "property_id",
+  },
+
   rows: {
     type: Array,
     default: () => [],
   },
 
+  totalLabel: {
+    type: String,
+    default: "Total",
+  },
+
+  totalRow: {
+    type: Object,
+    default: null,
+  },
+
   totalReservations: {
     type: Number,
     default: 0,
@@ -76,28 +93,50 @@ const props = defineProps({
   },
 });
 
-const columns = ref([
+const defaultColumns = [
   {
     key: "property_label",
     label: "Unidade",
+    type: "text",
     direction: null,
   },
 
   {
     key: "reservations_count",
     label: "Reservas",
+    type: "integer",
     direction: null,
   },
 
   {
     key: "final_payout_amount",
     label: "Valor",
+    type: "currency",
     direction: null,
   },
-]);
+];
+
+const tableColumns = ref([]);
+
+const cloneColumns = (value) => value.map((column) => ({ ...column }));
+
+const resolvedColumns = computed(() => tableColumns.value);
+
+const totalColumns = computed(() => resolvedColumns.value.slice(1));
+
+const resolvedTotalRow = computed(() => {
+  if (props.totalRow) {
+    return props.totalRow;
+  }
+
+  return {
+    reservations_count: props.totalReservations,
+    final_payout_amount: props.totalValue,
+  };
+});
 
 const toggleSort = (column) => {
-  columns.value.forEach((item) => {
+  tableColumns.value.forEach((item) => {
     if (item !== column) {
       item.direction = null;
     }
@@ -107,7 +146,9 @@ const toggleSort = (column) => {
 };
 
 const sortedRows = computed(() => {
-  const activeSorts = columns.value.filter((column) => column.direction);
+  const activeSorts = resolvedColumns.value.filter(
+    (column) => column.direction,
+  );
 
   if (!activeSorts.length) {
     return [...props.rows];
@@ -117,15 +158,8 @@ const sortedRows = computed(() => {
     for (const sort of activeSorts) {
       const direction = sort.direction === "asc" ? 1 : -1;
 
-      const valueA =
-        sort.key === "reservations_count"
-          ? resolveReservationsCount(a)
-          : a[sort.key];
-
-      const valueB =
-        sort.key === "reservations_count"
-          ? resolveReservationsCount(b)
-          : b[sort.key];
+      const valueA = resolveColumnValue(a, sort);
+      const valueB = resolveColumnValue(b, sort);
 
       if (valueA > valueB) {
         return direction;
@@ -140,6 +174,18 @@ const sortedRows = computed(() => {
   });
 });
 
+const resolveColumnValue = (item, column) => {
+  if (typeof column.value === "function") {
+    return column.value(item);
+  }
+
+  if (column.key === "reservations_count") {
+    return resolveReservationsCount(item);
+  }
+
+  return item[column.key];
+};
+
 const resolveReservationsCount = (item) =>
   Number(
     item.checkout_reservations_count ??
@@ -148,6 +194,22 @@ const resolveReservationsCount = (item) =>
       0,
   );
 
+const resolveRowKey = (item) => {
+  return item[props.rowKey] ?? item.key ?? item.id ?? JSON.stringify(item);
+};
+
+const formatCell = (value, column) => {
+  if (column.type === "currency") {
+    return formatCurrency(value);
+  }
+
+  if (column.type === "integer") {
+    return formatInteger(value);
+  }
+
+  return value ?? "";
+};
+
 const formatCurrency = (value) => {
   return new Intl.NumberFormat("pt-BR", {
     style: "currency",
@@ -159,6 +221,14 @@ const formatCurrency = (value) => {
 const formatInteger = (value) => {
   return Number(value ?? 0).toLocaleString("pt-BR");
 };
+
+watch(
+  () => props.columns,
+  (value) => {
+    tableColumns.value = cloneColumns(value?.length ? value : defaultColumns);
+  },
+  { immediate: true, deep: true },
+);
 </script>
 
 <style scoped lang="scss">
@@ -194,6 +264,10 @@ const formatInteger = (value) => {
   text-align: center;
 }
 
+.payout-empty-row {
+  color: #7b878c;
+}
+
 .header-wrapper {
   display: flex;
   flex-direction: column;

+ 22 - 0
src/router/routes.js

@@ -40,6 +40,28 @@ const routes = [
           ],
         },
       },
+      {
+        path: "despesas",
+        name: "DashboardExpensesPage",
+
+        component: () => import("pages/dashboard/DashboardExpensesPage.vue"),
+
+        meta: {
+          title: "Despesas do Proprietário",
+          requireAuth: true,
+
+          breadcrumbs: [
+            {
+              name: "DashboardPage",
+              title: "Dashboard do Proprietário",
+            },
+            {
+              name: "DashboardExpensesPage",
+              title: "Despesas",
+            },
+          ],
+        },
+      },
     ],
   },