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