فهرست منبع

refactor: ajustes para componentizar mais a seção do dashboard

Gustavo Mantovani 3 روز پیش
والد
کامیت
561aa560ce
43فایلهای تغییر یافته به همراه1362 افزوده شده و 3066 حذف شده
  1. 1 1
      package.json
  2. 1 2
      src/App.vue
  3. 0 1
      src/api/ownerDashboard.js
  4. 0 97
      src/components/charts/CardIconChart.vue
  5. 0 57
      src/components/charts/DefaultCard.vue
  6. 0 91
      src/components/charts/DonutChart.vue
  7. 0 113
      src/components/charts/mini/MiniBarChart.vue
  8. 0 123
      src/components/charts/mini/MiniLineChart.vue
  9. 83 0
      src/composables/useOwnerDashboard.js
  10. 75 0
      src/helpers/buildMetricCards.js
  11. 24 0
      src/helpers/convertBase64Image.js
  12. 9 9
      src/helpers/masks.js
  13. 157 185
      src/helpers/utils.js
  14. 3 3
      src/layouts/LoginLayout.vue
  15. 24 10
      src/layouts/MainLayout.vue
  16. 15 8
      src/pages/VersionPage.vue
  17. 1 0
      src/pages/WelcomePage.vue
  18. 151 567
      src/pages/dashboard/DashboardPage.vue
  19. 229 0
      src/pages/dashboard/components/DashboardAvailabilityPanel.vue
  20. 188 0
      src/pages/dashboard/components/DashboardChannelsPanel.vue
  21. 180 0
      src/pages/dashboard/components/DashboardFiltersBar.vue
  22. 28 29
      src/pages/dashboard/components/DashboardMetricCard.vue
  23. 17 14
      src/pages/dashboard/components/DashboardPayoutTable.vue
  24. 51 0
      src/pages/dashboard/components/DashboardRevenuePanel.vue
  25. 0 42
      src/pages/settings/SettingsPage.vue
  26. 0 189
      src/pages/settings/SettingsUserActionPage.vue
  27. 0 319
      src/pages/settings/SettingsUserTypeActionPage.vue
  28. 0 148
      src/pages/settings/SettingsUserTypesPage.vue
  29. 0 45
      src/pages/settings/SettingsUsersPage.vue
  30. 0 182
      src/pages/settings/components/SettingsDialog.vue
  31. 0 92
      src/pages/settings/components/SettingsTabsHeader.vue
  32. 0 217
      src/pages/settings/utils.js
  33. 15 5
      src/router/index.js
  34. 17 5
      src/router/routes.js
  35. 0 33
      src/router/routes/guest.route.js
  36. 0 92
      src/router/routes/property.route.js
  37. 0 86
      src/router/routes/recepcionist.route.js
  38. 0 134
      src/router/routes/service.route.js
  39. 0 90
      src/router/routes/users.route.js
  40. 8 4
      src/router/routes/version.route.js
  41. 6 6
      src/stores/navigation.js
  42. 68 57
      src/stores/permission.js
  43. 11 10
      src/stores/user.js

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "alugap",
-  "version": "0.5.0",
+  "version": "0.1.0",
   "description": "A skeleton for future projects",
   "productName": "Quasar App",
   "author": "Denis <denis.gnl@gmail.com>",

+ 1 - 2
src/App.vue

@@ -20,8 +20,7 @@ watch(
   () => locale.value,
   (value) => {
     Cookies.set("locale", value, {
-      expires: 365,
-      path: "/",
+      expires: 365, path: "/",
     });
   },
 );

+ 0 - 1
src/api/ownerDashboard.js

@@ -2,7 +2,6 @@ import { api } from "src/boot/axios";
 
 export const getOwnerDashboard = async (params = {}) => {
   const { data } = await api.get("/owner-dashboard", { params });
-
   return data.payload;
 };
 

+ 0 - 97
src/components/charts/CardIconChart.vue

@@ -1,97 +0,0 @@
-<template>
-  <q-card flat class="q-pa-lg">
-    <div class="column no-wrap full-width">
-      <div class="flex items-center no-wrap">
-        <div class="round background q-mr-sm">
-          <q-icon
-            class="q-pa-sm"
-            :name="props.icon"
-            size="24px"
-            :color="props.color"
-          />
-        </div>
-        <span class="text-h5">{{ props.title }}</span>
-      </div>
-      <div class="flex no-wrap full-width justify-between q-pa-sm">
-        <div class="column flex-center">
-          <span class="text-h3">{{ props.numberCard }}</span>
-          <div
-            class="flex no-wrap text-subtitle2"
-            :class="props.numberPorcent > 0 ? 'text-positive' : 'text-negative'"
-          >
-            <q-icon
-              :name="
-                props.numberPorcent > 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'
-              "
-              size="18px"
-              class="q-mr-xs"
-            />
-            {{ props.numberPorcent + "%" }}
-          </div>
-        </div>
-        <div class="flex justify-end" style="max-width: 120px; height: 80px;">
-          <slot name="chart">
-            <MiniLineChart
-              :data="chartData"
-              line-color="#1976D2"
-              fill-color="rgba(0, 0, 0, 0)"
-            />
-
-            <MiniBarChart
-              :data="chartData"
-              bar-color="#1976D2"
-            />
-          </slot>
-        </div>
-      </div>
-    </div>
-  </q-card>
-</template>
-
-<script setup>
-// import MiniLineChart from "./mini/MiniLineChart.vue";
-import MiniBarChart from "./mini/MiniBarChart.vue";
-const props = defineProps({
-  color: {
-    type: String,
-    default: "primary",
-  },
-  title: {
-    type: String,
-    default: "Usuários",
-  },
-  icon: {
-    type: String,
-    default: "mdi-account",
-  },
-  chartData: {
-    type: Array,
-    default: () =>
-      Array.from({ length: 7 }, () => Math.floor(Math.random() * 100)),
-  },
-  numberCard: {
-    type: Number,
-    default: () => Math.floor(Math.random() * 100),
-  },
-  numberPorcent: {
-    type: Number,
-    default: () => Math.ceil(Math.random() * 200 - 100),
-  },
-});
-</script>
-<style lang="scss" scoped>
-@use "sass:map";
-@use "src/css/quasar.variables.scss";
-
-body.body--light {
-  .background {
-    background: rgba(map.get($colors, "primary"), 0.2) !important;
-  }
-}
-
-body.body--dark {
-  .background {
-    background: rgba(map.get($colors-dark, "primary"), 0.2) !important;
-  }
-}
-</style>

+ 0 - 57
src/components/charts/DefaultCard.vue

@@ -1,57 +0,0 @@
-<template>
-  <q-card flat class="users-card q-pa-md">
-    <div class="card-header q-mb-md">
-      <h6 class="text-h6 q-ma-none">{{ title }}</h6>
-      <q-separator />
-    </div>
-    <div class="card-content">
-      <DonutChart :total-users="total" />
-    </div>
-  </q-card>
-</template>
-
-<script setup>
-import DonutChart from "./DonutChart.vue";
-
-defineProps({
-  total: {
-    type: Number,
-    default: 1,
-  },
-  title: {
-    type: String,
-    default: "",
-  },
-});
-</script>
-
-<style scoped>
-.users-card {
-  border-radius: 8px;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
-  min-height: 250px;
-  display: flex;
-  flex-direction: column;
-}
-
-.card-header {
-  text-align: left;
-}
-
-.card-content {
-  flex: 1;
-  display: flex;
-  justify-content: center;
-  align-items: center;
-}
-
-body.body--light .users-card {
-  background: white;
-  border: 1px solid #e0e0e0;
-}
-
-body.body--dark .users-card {
-  background: #1e1e1e;
-  border: 1px solid #404040;
-}
-</style>

+ 0 - 91
src/components/charts/DonutChart.vue

@@ -1,91 +0,0 @@
-<template>
-  <div class="donut-chart-container">
-    <div class="chart-wrapper">
-      <Doughnut
-        :data="chartData"
-        :options="chartOptions"
-        :plugins="[centerTextPlugin]"
-      />
-      <div class="center-text">
-        <div class="center-number text-text">{{ totalUsers }}</div>
-      </div>
-    </div>
-  </div>
-</template>
-
-<script setup>
-import { computed } from "vue";
-import { Doughnut } from "vue-chartjs";
-import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
-
-ChartJS.register(ArcElement, Tooltip, Legend);
-
-const props = defineProps({
-  totalUsers: {
-    type: Number,
-    default: 1,
-  },
-  color: {
-    type: String,
-    default: "#08514C",
-  },
-});
-
-const chartData = computed(() => ({
-  datasets: [
-    {
-      data: [props.totalUsers],
-      backgroundColor: [props.color],
-      borderWidth: 0,
-      cutout: "70%",
-    },
-  ],
-}));
-
-const chartOptions = {
-  responsive: true,
-  maintainAspectRatio: true,
-  plugins: {
-    legend: {
-      display: false,
-    },
-    tooltip: {
-      enabled: false,
-    },
-  },
-};
-
-const centerTextPlugin = {
-  id: "centerText",
-  beforeDraw: () => {},
-};
-</script>
-
-<style scoped>
-.donut-chart-container {
-  position: relative;
-  width: 150px;
-  height: 150px;
-}
-
-.chart-wrapper {
-  position: relative;
-  width: 100%;
-  height: 100%;
-}
-
-.center-text {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  text-align: center;
-  pointer-events: none;
-}
-
-.center-number {
-  font-size: 32px;
-  font-weight: bold;
-  line-height: 1;
-}
-</style>

+ 0 - 113
src/components/charts/mini/MiniBarChart.vue

@@ -1,113 +0,0 @@
-<template>
-  <Bar
-    :id="props.id"
-    ref="chart_ref"
-    :options="chartOptions"
-    :data="computedChartData"
-  />
-</template>
-
-<script setup>
-import {
-  Chart as ChartJS,
-  CategoryScale,
-  LinearScale,
-  BarElement,
-  Tooltip,
-} from "chart.js";
-import { computed, useTemplateRef } from "vue";
-import { Bar } from "vue-chartjs";
-
-// Register only necessary components
-ChartJS.register(
-  CategoryScale,
-  LinearScale,
-  BarElement,
-  Tooltip,
-);
-
-const chart_ref = useTemplateRef(null);
-
-// Simplified props focusing on essential functionality
-const props = defineProps({
-  // Core data props
-  data: {
-    type: Array,
-    required: true,
-  },
-
-  // Essential styling
-  barColor: {
-    type: String,
-    default: "#1976D2",
-  },
-
-  // Optional configurations for flexibility
-  horizontal: {
-    type: Boolean,
-    default: false,
-  },
-  showTooltip: {
-    type: Boolean,
-    default: true,
-  }
-});
-
-// Optimized chart options for mini-charts
-const chartOptions = computed(() => ({
-  responsive: true,
-  maintainAspectRatio: false,
-  indexAxis: props.horizontal ? "y" : "x",
-
-  plugins: {
-    legend: {
-      display: false, // Always hide legend in mini charts
-    },
-    tooltip: {
-      enabled: props.showTooltip,
-      displayColors: false,
-      callbacks: {
-        label: (context) => `${context.raw}`
-      }
-    },
-  },
-
-  scales: {
-    x: {
-      display: false,
-      grid: {
-        display: false,
-      }
-    },
-    y: {
-      display: false,
-      grid: {
-        display: false,
-      },
-      beginAtZero: true,
-    },
-  },
-
-  animation: {
-    duration: 750,
-    easing: 'easeOutQuad',
-  },
-}));
-
-// Simplified data computation
-const computedChartData = computed(() => ({
-  labels: Array(props.data.length).fill(''),
-  datasets: [{
-    data: props.data,
-    backgroundColor: props.barColor,
-    borderRadius: 2,
-    barThickness: 8,
-    maxBarThickness: 10,
-  }]
-}));
-
-// Expose essential methods
-defineExpose({
-  chart_ref,
-});
-</script>

+ 0 - 123
src/components/charts/mini/MiniLineChart.vue

@@ -1,123 +0,0 @@
-<template>
-  <Line
-    ref="chart_ref"
-    :options="chartOptions"
-    :data="computedChartData"
-  />
-</template>
-
-<script setup>
-import {
-  Chart as ChartJS,
-  CategoryScale,
-  LinearScale,
-  LineElement,
-  PointElement,
-  Tooltip,
-  Filler
-} from "chart.js";
-import { computed, useTemplateRef } from "vue";
-import { Line } from "vue-chartjs";
-
-// Register only essential components
-ChartJS.register(
-  CategoryScale,
-  LinearScale,
-  LineElement,
-  PointElement,
-  Tooltip,
-  Filler
-);
-
-const chart_ref = useTemplateRef(null);
-
-const props = defineProps({
-  // Essential data props
-  data: {
-    type: Array,
-    required: true,
-  },
-
-  // Core styling
-  lineColor: {
-    type: String,
-    default: "#1976D2",
-  },
-  fillColor: {
-    type: String,
-    default: "rgba(25, 118, 210, 0.1)",
-  },
-
-  // Optional display features
-  showTooltip: {
-    type: Boolean,
-    default: true,
-  },
-  showPoints: {
-    type: Boolean,
-    default: false,
-  }
-});
-
-const chartOptions = computed(() => ({
-  responsive: true,
-  maintainAspectRatio: false,
-
-  plugins: {
-    legend: {
-      display: false, // Always hidden for mini charts
-    },
-    tooltip: {
-      enabled: props.showTooltip,
-      displayColors: false,
-      callbacks: {
-        label: (context) => `${context.raw}`
-      }
-    },
-  },
-
-  scales: {
-    x: {
-      display: false,
-      grid: { display: false }
-    },
-    y: {
-      display: false,
-      grid: { display: false },
-      beginAtZero: true
-    }
-  },
-
-  elements: {
-    line: {
-      tension: 0.4,
-      borderWidth: 2,
-    },
-    point: {
-      radius: props.showPoints ? 3 : 0,
-      hitRadius: 5,
-      borderWidth: 0,
-      backgroundColor: props.showPoints ? props.lineColor : "rgba(0,0,0,0)",
-    }
-  },
-
-  animation: {
-    duration: 750,
-    easing: 'easeOutQuad',
-  }
-}));
-
-const computedChartData = computed(() => ({
-  labels: Array(props.data.length).fill(''),
-  datasets: [{
-    data: props.data,
-    borderColor: props.lineColor,
-    backgroundColor: props.fillColor,
-    fill: true,
-  }]
-}));
-
-defineExpose({
-  chart_ref,
-});
-</script>

+ 83 - 0
src/composables/useOwnerDashboard.js

@@ -0,0 +1,83 @@
+import { computed, ref } from "vue";
+import { getOwnerDashboard } from "src/api/ownerDashboard";
+
+export function useOwnerDashboard() {
+  const dashboard     = ref(null);
+  const loading       = ref(false);
+  const revenueSeries = ref([]);
+
+  const summary  = computed(() => dashboard.value?.summary ?? {});
+  const channels = computed(() => dashboard.value?.channels ?? []);
+
+  const availableReferences = computed(
+    () => dashboard.value?.filters?.available_references ?? [],
+  );
+
+  const selectedFilters = computed(
+    () => dashboard.value?.filters?.selected ?? {},
+  );
+
+  const fetchDashboard = async (params = {}) => {
+    loading.value = true;
+
+    try {
+      const payload = await getOwnerDashboard(params);
+
+      dashboard.value = payload;
+
+      await fetchRevenueHistory(payload);
+    } finally {
+      loading.value = false;
+    }
+  };
+
+  const fetchRevenueHistory = async (payload) => {
+    const references = (payload?.filters?.available_references ?? [])
+      .slice(0, 6)
+      .reverse();
+
+    if (!references.length) {
+      revenueSeries.value = [];
+
+      return;
+    }
+
+    try {
+      const propertyId = payload?.filters?.selected?.property_id ?? null;
+
+      const responses = await Promise.all(
+        references.map(async (reference) => {
+          const historyPayload = await getOwnerDashboard({
+            year:        reference.reference_year,
+            month:       reference.reference_month,
+            property_id: propertyId,
+          });
+
+          return {
+            label: new Intl.DateTimeFormat("pt-BR", {
+              month: "short",
+            })
+              .format(new Date(reference.reference_year, reference.reference_month - 1, 1))
+              .replace(".", ""),
+            value: Number(historyPayload?.summary?.reserve_total ?? 0),
+          };
+        }),
+      );
+
+      revenueSeries.value = responses;
+    } catch {
+      revenueSeries.value = [];
+    }
+  };
+
+  return {
+    availableReferences,
+    channels,
+    dashboard,
+    loading,
+    revenueSeries,
+    selectedFilters,
+    summary,
+    fetchDashboard,
+  };
+}

+ 75 - 0
src/helpers/buildMetricCards.js

@@ -0,0 +1,75 @@
+import {
+  formatCurrency,
+  formatDecimal,
+  formatInteger,
+  formatPercent,
+} from "./utils";
+
+export function buildMetricCards(summary, options = {}) {
+  const {
+    isAllPropertiesSelected = false,
+    referenceLabel          = "",
+  } = options;
+
+  const totalExpenses = Number(summary.total_forward_fee_all ?? 0)
+    + Number(summary.total_expenses_amount ?? 0);
+
+  const ownerPayout = Number(summary.owner_payout_amount ?? 0);
+  const netPayout   = ownerPayout - totalExpenses;
+  const occupancyCaption = isAllPropertiesSelected
+    ? "percentual consolidado do período"
+    : `${formatInteger(summary.occupied_nights_in_month)} de ${formatInteger(summary.days_in_month)} dias`;
+
+  return [
+    {
+      label:   "Faturamento Bruto",
+      value:   formatCurrency(summary.reserve_total),
+      caption: referenceLabel || "Mês selecionado",
+    },
+    {
+      label:   "Faturamento Líquido",
+      value:   formatCurrency(summary.owner_payout_amount),
+      caption: "Após deduções",
+    },
+    {
+      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",
+    },
+  ];
+}

+ 24 - 0
src/helpers/convertBase64Image.js

@@ -1,58 +1,82 @@
 const base64ToJPEG = (base64String, fileName) => {
   // Remova a parte inicial "data:image/jpeg;base64,"
+
   const base64WithoutHeader = base64String.replace(
     /^data:image\/jpeg;base64,/,
     ""
   );
 
   // Converte a string base64 para um array de bytes
+
   const byteCharacters = atob(base64WithoutHeader);
+
   const byteNumbers = new Array(byteCharacters.length);
+
   for (let i = 0; i < byteCharacters.length; i++) {
     byteNumbers[i] = byteCharacters.charCodeAt(i);
   }
+
   const byteArray = new Uint8Array(byteNumbers);
 
   // Cria um objeto Blob contendo os dados da imagem
+
   const blob = new Blob([byteArray], { type: "image/png" });
 
   // Cria um link para download
+
   const link = document.createElement("a");
+
   link.href = URL.createObjectURL(blob);
+
   link.download = fileName || "image.png";
 
   // Adiciona o link ao documento, clica nele e remove-o
+
   document.body.appendChild(link);
+
   link.click();
+
   document.body.removeChild(link);
 };
 
 const base64ToPNG = (base64String, fileName) => {
   // Remova a parte inicial "data:image/jpeg;base64,"
+
   const base64WithoutHeader = base64String.replace(
     /^data:image\/png;base64,/,
     ""
   );
 
   // Converte a string base64 para um array de bytes
+
   const byteCharacters = atob(base64WithoutHeader);
+
   const byteNumbers = new Array(byteCharacters.length);
+
   for (let i = 0; i < byteCharacters.length; i++) {
     byteNumbers[i] = byteCharacters.charCodeAt(i);
   }
+
   const byteArray = new Uint8Array(byteNumbers);
 
   // Cria um objeto Blob contendo os dados da imagem
+
   const blob = new Blob([byteArray], { type: "image/png" });
 
   // Cria um link para download
+
   const link = document.createElement("a");
+
   link.href = URL.createObjectURL(blob);
+
   link.download = fileName || "image.png";
 
   // Adiciona o link ao documento, clica nele e remove-o
+
   document.body.appendChild(link);
+
   link.click();
+
   document.body.removeChild(link);
 }
 

+ 9 - 9
src/helpers/masks.js

@@ -7,18 +7,18 @@
 // x -> alphanumerico (minusculo)
 const masks = {
   Brasil: {
-    cpf: "###.###.###-##",
+    cpf:        "###.###.###-##",
     docEmpresa: "##.###.###/####-##",
-    celular: "(##) # ####-####",
-    telefone: "(##) ####-####",
-    cep: "#####-###",
-    cnpj: "##.###.###/####-##",
-    date: "##/##/####",
-    datetime: "##/##/#### ##:##",
+    celular:    "(##) # ####-####",
+    telefone:   "(##) ####-####",
+    cep:        "#####-###",
+    cnpj:       "##.###.###/####-##",
+    date:       "##/##/####",
+    datetime:   "##/##/#### ##:##",
   },
   Paraguay: {
-    celular: "(###) ###-###",
-    telefone: "## ### ###",
+    celular:    "(###) ###-###",
+    telefone:   "## ### ###",
     docEmpresa: "#######-#",
   },
   placasVeiculo: {

+ 157 - 185
src/helpers/utils.js

@@ -1,253 +1,221 @@
 import { useI18n } from "vue-i18n";
 
-/**
- * @description Corta uma string em um determinado tamanho.
- * @param {string} string string a ser cortada.
- * @param {string} size tamanho da string.
- * @returns {string} string cortada.
- */
+const checaMoeda = (moeda) => {
+  let currencyOptions = {};
+
+  if (moeda == 1) {
+    currencyOptions = {
+      locale:          "pt-BR",
+      currency:        "BRL",
+      currencyDisplay: "symbol",
+
+      hideCurrencySymbolOnFocus:          false,
+      hideGroupingSeparatorOnFocus:       false,
+      hideNegligibleDecimalDigitsOnFocus: false,
+      autoDecimalDigits:                  true,
+      useGrouping:                        true,
+      accountingSign:                     false,
+    };
+  } else if (moeda == 2) {
+    currencyOptions = {
+      currency:        "PYG",
+      locale:          "es-PY",
+      valueAsInteger:  true,
+      distractionFree: true,
+      precision:       0,
+      autoDecimalMode: true,
+
+      valueRange: { min: 0 },
+
+      allowNegative: true,
+    };
+  } else if (moeda == 3) {
+    currencyOptions = {
+      locale:          "en-US",
+      currency:        "USD",
+      currencyDisplay: "symbol",
+
+      hideCurrencySymbolOnFocus:          true,
+      hideGroupingSeparatorOnFocus:       true,
+      hideNegligibleDecimalDigitsOnFocus: false,
+      autoDecimalDigits:                  true,
+      useGrouping:                        true,
+      accountingSign:                     false,
+    };
+  }
+
+  return currencyOptions;
+};
+
 const excerpt = (string, size = 30) => {
   if (size == null) return string;
+
   if (string.length > size) {
     string = string.substring(0, size) + "...";
   }
+
   return string;
 };
 
-/**
- * @description Formata uma data de DD/MM/YYYY para YYYY-MM-DD
- * @param {string} date data.
- * @param {string} time tempo.
- * @throws {Error} Caso a data seja nula ou invalida.
- * @returns {string} data formatada.
- */
-const formatDateDMYtoYMD = (date, time) => {
-  if (!date) throw new Error(useI18n().t("validation.rules.required"));
-  const testDate =
-    /^([0-2][0-9]|(3)[0-1])(\/)(((0)[0-9])|((1)[0-2]))(\/)\d{4}$/;
-  if (testDate.test(date) === false)
-    throw new Error(useI18n().t("validation.rules.date"));
+// formatters
 
-  const [day, month, year] = date.split("/");
-  return `${year}-${month}-${day} ${time ? time : ""}`;
-};
+// formatters de data e hora
 
-/**
- * @description Converte uma data e hora para o formato brasileiro.
- * @param {string} dateTimeString data e hora.
- * @returns {string} data e hora no formato brasileiro.
- * @throws {Error} Caso a data seja nula ou invalida.
- * @returns {string} data formatada.
- * @example
- * // convertDateTime("2023-05-23T13:07:27.000000Z");
- * // Output: 23/05/2023 10:07:27
- */
 const convertDateTime = (dateTimeString) => {
   const dateTime = new Date(dateTimeString);
+
   const options = {
     timeZone: "America/Sao_Paulo",
-    day: "2-digit",
-    month: "2-digit",
-    year: "numeric",
-    hour: "2-digit",
-    minute: "2-digit",
-    second: "2-digit",
+    day:      "2-digit",
+    month:    "2-digit",
+    year:     "numeric",
+    hour:     "2-digit",
+    minute:   "2-digit",
+    second:   "2-digit",
   };
+
   const formattedDateTime = dateTime
     .toLocaleString("pt-BR", options)
     .replace(",", "");
+
   return formattedDateTime;
 };
 
-/**
- * @description Formata uma data de YYYY-MM-DD para DD/MM/YYYY
- * @param {string} dateTime data e hora.
- * @returns {string} data e hora no formato brasileiro.
- * @example
- * // formatDateYMDtoDMY("2023-05-23T13:07:27.000000Z");
- * // Output: 23/05/2023 10:07:27
- */
+const formatDateDMYtoYMD = (date, time) => {
+  if (!date) throw new Error(useI18n().t("validation.rules.required"));
+
+  const testDate =
+    /^([0-2][0-9]|(3)[0-1])(\/)(((0)[0-9])|((1)[0-2]))(\/)\d{4}$/;
+
+  if (testDate.test(date) === false)
+    throw new Error(useI18n().t("validation.rules.date"));
+
+  const [day, month, year] = date.split("/");
+
+  return `${year}-${month}-${day} ${time ? time : ""}`;
+};
+
 const formatDateYMDtoDMY = (dateTime) => {
   const [datePart, timePart] = dateTime.split(" ");
+
   const [year, month, day] = datePart.split("-");
+
   const formattedDate = `${day}/${month}/${year}`;
+
   if (timePart) {
     const [hours, minutes, seconds] = timePart.split(":");
+
     const formattedTime = `${hours}:${minutes}:${seconds}`;
+
     return `${formattedDate} ${formattedTime}`;
   }
+
   return formattedDate;
 };
 
-/**
- * @description Checa a moeda selecionada.
- * @param {number} moeda moeda selecionada.
- * @returns {object} opções de moeda.
- */
-const checaMoeda = (moeda) => {
-  let currencyOptions = {};
-  if (moeda == 1) {
-    currencyOptions = {
-      locale: "pt-BR",
-      currency: "BRL",
-      currencyDisplay: "symbol",
-      hideCurrencySymbolOnFocus: false,
-      hideGroupingSeparatorOnFocus: false,
-      hideNegligibleDecimalDigitsOnFocus: false,
-      autoDecimalDigits: true,
-      useGrouping: true,
-      accountingSign: false,
-    };
-  } else if (moeda == 2) {
-    currencyOptions = {
-      currency: "PYG",
-      locale: "es-PY",
-      valueAsInteger: true,
-      distractionFree: true,
-      precision: 0,
-      autoDecimalMode: true,
-      valueRange: { min: 0 },
-      allowNegative: true,
-    };
-  } else if (moeda == 3) {
-    currencyOptions = {
-      locale: "en-US",
-      currency: "USD",
-      currencyDisplay: "symbol",
-      hideCurrencySymbolOnFocus: true,
-      hideGroupingSeparatorOnFocus: true,
-      hideNegligibleDecimalDigitsOnFocus: false,
-      autoDecimalDigits: true,
-      useGrouping: true,
-      accountingSign: false,
-    };
+// formatters de moeda e number
+
+const convertToCents = (value) => {
+  if (value === null || value === undefined || value === "") return null;
+
+  if (typeof value === "number") {
+    return Math.round(value * 100);
   }
-  return currencyOptions;
+
+  if (typeof value === "string") {
+    const cleanValue = value.replace(/[R$\s.]/g, "").replace(",", ".");
+
+    const numericValue = parseFloat(cleanValue);
+
+    return isNaN(numericValue) ? null : Math.round(numericValue * 100);
+  }
+
+  return null;
+};
+
+const formatCurrency = (value) => {
+  return new Intl.NumberFormat("pt-BR", {
+    style:    "currency",
+    currency: "BRL",
+    minimumFractionDigits: 2,
+  }).format(Number(value ?? 0));
+};
+
+const formatCurrencyCompact = (value) => {
+  return new Intl.NumberFormat("pt-BR", {
+    style:    "currency",
+    currency: "BRL",
+    notation: "compact",
+    maximumFractionDigits: 1,
+  }).format(Number(value ?? 0));
+};
+
+const formatDecimal = (value) => {
+  return Number(value ?? 0).toLocaleString("pt-BR", {
+    minimumFractionDigits: 1,
+    maximumFractionDigits: 1,
+  });
 };
 
-/**
- * @description Filtra a moeda.
- * @param {number} value valor.
- * @returns {string} valor formatado.
- */
+const formatInteger = (value) => {
+  return Number(value ?? 0).toLocaleString("pt-BR");
+};
+
+const formatPercent = (value, digits = 2) => {
+  return `${Number(value ?? 0).toFixed(digits).replace(".", ",")}%`;
+};
+
+// filters
+
 const filterCurrency = (value) => {
   if (value) {
     value = parseFloat(value);
+
     return value.toLocaleString("pt-BR", {
-      style: "currency",
+      style:    "currency",
       currency: "BRL",
     });
   }
+
   return value;
 };
 
-/**
- * @description Filtra a unidade de medida.
- * @param {number} value valor.
- * @returns {string} valor formatado.
- */
 const filterUnidadeMedida = (value) => {
   return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
 };
 
-/**
- * @description Valida se a data é válida.
- * @param {string} date data.
- * @returns {boolean} true se a data é válida, false caso contrário.
- */
+// validators
+
 const validaData = (date) => {
   const regex = /^(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2])\/\d{4}$/;
+
   return regex.test(date);
 };
 
-/**
- * @description Valida se a hora é válida.
- * @param {string} time hora.
- * @returns {boolean} true se a hora é válida, false caso contrário.
- */
 const validaHora = (time) => {
   const regex = /^([0-1][0-9]|2[0-3]):[0-5][0-9]$/;
+
   return regex.test(time);
 };
 
-/**
- * @description Valida se a data e hora são válidas.
- * @param {string} dataHora data e hora.
- * @returns {boolean} true se a data e hora são válidas, false caso contrário.
- */
 const validaDataHora = (dataHora) => {
   const regex =
     /^(0[1-9]|[12][0-9]|3[01])\/(0[1-9]|1[0-2])\/\d{4}\s([0-1][0-9]|2[0-3]):[0-5][0-9]$/;
+
   return regex.test(dataHora);
 };
 
-/**
- * @description Formata a quantidade.
- * @param {number} value valor.
- * @returns {string} valor formatado.
- */
-const formatQuantity = (value) => {
-  if (value) {
-    return value
-      .toString()
-      .replace(/[^0-9]/g, "")
-      .replace(/\B(?=(\d{3})+(?!\d))/g, ".");
-  }
-  return value;
-};
-
-/**
- * @description Formata a moeda.
- * @param {number} value valor.
- * @returns {string} valor formatado.
- */
-const formatCurrency = (value) => {
-  if (value != null) {
-    value = parseFloat(value);
-    return value.toLocaleString("pt-BR", {
-      minimumFractionDigits: 2,
-      style: "currency",
-      currency: "BRL",
-    });
-  }
-  return value;
-};
+// outros
 
 const truncatedName = (name) => {
   if (name.length <= 15) return name;
-  const extension = name.split(".").pop();
-  const nameWithoutExt = name.substring(0, name.lastIndexOf("."));
-  return `${nameWithoutExt.substring(0, 8)}...${extension}`;
-};
 
-const convertToCents = (value) => {
-  if (value === null || value === undefined || value === "") return null;
-
-  if (typeof value === "number") {
-    return Math.round(value * 100);
-  }
-
-  if (typeof value === "string") {
-    const cleanValue = value.replace(/[R$\s.]/g, "").replace(",", ".");
-    const numericValue = parseFloat(cleanValue);
-    return isNaN(numericValue) ? null : Math.round(numericValue * 100);
-  }
+  const extension      = name.split(".").pop();
+  const nameWithoutExt = name.substring(0, name.lastIndexOf("."));
 
-  return null;
+  return `${nameWithoutExt.substring(0, 8)}...${extension}`;
 };
 
-/**
- * Mascara um número de CPF, exibindo apenas os três primeiros e os dois últimos dígitos.
- * A função lida com CPFs formatados (ex: '123.456.789-00') ou apenas com números.
- *
- * @param {string | null | undefined} cpf O CPF a ser mascarado.
- * @returns {string} O CPF mascarado (ex: '123.***.***-00') ou uma string vazia se a entrada for inválida.
- *
- * @example
- * maskCpf('12345678900'); // '123.***.***-00'
- * maskCpf('123.456.789-00'); // '123.***.***-00'
- * maskCpf(null); // ''
- */
 export const maskCpf = (cpf) => {
   if (!cpf) {
     return "";
@@ -259,28 +227,32 @@ export const maskCpf = (cpf) => {
     console.warn(
       "CPF inválido fornecido para a máscara. Retornando valor original.",
     );
+
     return cpf;
   }
 
   const firstPart = cleanedCpf.slice(0, 3);
-  const lastPart = cleanedCpf.slice(9, 11);
+  const lastPart  = cleanedCpf.slice(9, 11);
 
   return `${firstPart}.***.***-${lastPart}`;
 };
 
 export {
-  formatDateDMYtoYMD,
-  formatDateYMDtoDMY,
-  excerpt,
-  convertDateTime,
   checaMoeda,
+  convertDateTime,
+  convertToCents,
+  excerpt,
   filterCurrency,
   filterUnidadeMedida,
-  validaData,
-  validaHora,
-  validaDataHora,
-  formatQuantity,
   formatCurrency,
+  formatCurrencyCompact,
+  formatDateDMYtoYMD,
+  formatDateYMDtoDMY,
+  formatDecimal,
+  formatInteger,
+  formatPercent,
   truncatedName,
-  convertToCents,
+  validaData,
+  validaDataHora,
+  validaHora,
 };

+ 3 - 3
src/layouts/LoginLayout.vue

@@ -2,9 +2,9 @@
   <q-layout view="hHh lpR fFf">
     <q-page-container
       style="
-        background-image: url(&quot;images/bg-login.svg&quot;);
-        background-repeat: no-repeat;
-        background-size: cover;
+        background-image:    url(&quot;images/bg-login.svg&quot;);
+        background-repeat:   no-repeat;
+        background-size:     cover;
         background-position: center;
       "
     >

+ 24 - 10
src/layouts/MainLayout.vue

@@ -1,7 +1,9 @@
 <template>
   <q-layout class="relative" view="hHh lpR fFf">
     <LeftMenuLayout v-if="!$q.screen.lt.sm" />
+
     <LeftMenuLayoutMobile v-else v-model="leftDrawerOpen" />
+
     <q-header v-if="$q.screen.lt.sm" class="bg-transparent q-pa-sm">
       <q-toolbar
         class="flex justify-between bg-surface"
@@ -10,42 +12,47 @@
         <q-btn dense flat @click="toggleLeftDrawer">
           <q-icon name="menu" :color="$q.dark.isActive ? 'white' : 'black'" />
         </q-btn>
+
         <q-btn dense flat>
           <img
             :src="someAvatar()"
             alt="avatar"
             style="width: 20px; height: 20px; border-radius: 50%"
           />
+
           <q-menu anchor="center right" self="top start">
             <q-list class="column no-wrap overflow-hidden">
               <q-item
                 v-ripple
                 v-close-popup
                 clickable
-                :to="{ name: 'ProfilePage' }"
                 exact
                 exact-active-class="menu-selected"
+                :to="{ name: 'ProfilePage' }"
               >
                 <div class="flex">
                   <q-item-section avatar>
                     <q-icon
-                      name="account_circle"
                       color="primary"
+                      name="account_circle"
                       style="font-size: 18px"
                     />
                   </q-item-section>
+
                   <q-item-section>{{ $t("user.profile.singular") }}</q-item-section>
                 </div>
               </q-item>
+
               <q-item v-ripple clickable @click="logoutFn">
                 <div class="flex">
                   <q-item-section avatar>
                     <q-icon
-                      name="logout"
                       color="negative"
+                      name="logout"
                       style="font-size: 18px"
                     />
                   </q-item-section>
+
                   <q-item-section>{{ $t('auth.logout') }}</q-item-section>
                 </div>
               </q-item>
@@ -54,6 +61,7 @@
         </q-btn>
       </q-toolbar>
     </q-header>
+
     <q-page-container>
       <q-page>
         <q-scroll-area
@@ -68,10 +76,10 @@
             <Transition mode="out-in">
               <component
                 :is="Component"
-                style="padding: 20px !important; padding-right: 10px !important"
                 :style="
                   $q.screen.lt.sm ? 'padding-left: 10px !important;' : ''
                 "
+                style="padding: 20px !important; padding-right: 10px !important"
               />
             </Transition>
           </router-view>
@@ -86,6 +94,7 @@ import { ref, useTemplateRef, watch } from "vue";
 import { useRoute } from "vue-router";
 import { useAuth } from "src/composables/useAuth";
 import { useRouter } from "vue-router";
+
 import LeftMenuLayout from "src/components/layout/LeftMenuLayout.vue";
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
 
@@ -94,11 +103,14 @@ defineOptions({
 });
 
 const { logout } = useAuth();
-const route = useRoute();
-const leftDrawerOpen = ref(false);
-const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const route  = useRoute();
 const router = useRouter();
 
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const leftDrawerOpen = ref(false);
+
 let oldValue = route.path;
 
 const someAvatar = () => {
@@ -107,6 +119,7 @@ const someAvatar = () => {
 
 const logoutFn = async () => {
   await logout();
+
   router.push({ name: "LoginPage" });
 };
 
@@ -119,23 +132,24 @@ watch(route, (value) => {
     scrollAreaRef.value.setScrollPosition("vertical", 0, 0);
     scrollAreaRef.value.setScrollPosition("horizontal", 0, 0);
   }
+
   oldValue = value.path;
 });
 </script>
 <style scoped>
 .v-enter-active {
-  opacity: 1;
+  opacity:    1;
   transition: all 0.15s ease-in;
 }
 
 .v-leave-active {
-  opacity: 1;
+  opacity:    1;
   transition: all 0.15s ease-out;
 }
 
 .v-enter-from,
 .v-leave-to {
-  opacity: 0;
+  opacity:    0;
   transition: all 0.15s ease-in;
 }
 

+ 15 - 8
src/pages/VersionPage.vue

@@ -1,6 +1,7 @@
 <template>
   <q-page class="q-pa-md">
     <DefaultHeaderPage />
+
     <div class="subtitle-1 q-my-md text-dark q-mx-lg">
       Histórico de atualizações
     </div>
@@ -9,22 +10,25 @@
       Versão atual:
       <span class="body1 text-dark">{{ version.version }}</span>
     </div>
+
     <q-table
       :columns="columns"
+      :pagination="{ rowsPerPage: 0 }"
       :rows="versoes"
       class="q-my-md softpar-table"
       flat
       hide-bottom
-      :pagination="{ rowsPerPage: 0 }"
     >
       <template #no-data>
         <div class="q-mx-auto q-pa-md body2">Nenhum registro encontrado</div>
       </template>
+
       <template #body="props">
         <q-tr :props="props" class="body2 text-dark">
           <q-td key="versao" :props="props">
             {{ props.row.versao }}
           </q-td>
+
           <q-td key="atualizacoes" :props="props">
             <div
               v-for="atualizacao in props.row.atualizacoes"
@@ -35,16 +39,17 @@
                 <div
                   class="text-bold bg-dark-4 q-mb-xs"
                   style="
-                    height: 20px;
-                    width: fit-content;
-                    margin-left: 5px;
+                    height:        20px;
+                    width:         fit-content;
+                    margin-left:   5px;
                     border-radius: 5px;
-                    padding-left: 5px;
+                    padding-left:  5px;
                     padding-right: 5px;
                   "
                 >
                   {{ atualizacao.tipo }}
                 </div>
+
                 <div v-for="mudanca in atualizacao.mudancas" :key="mudanca">
                   <div style="white-space: normal" class="q-mb-xs">
                     - {{ mudanca.descricao }}
@@ -53,6 +58,7 @@
               </div>
             </div>
           </q-td>
+
           <q-td key="data" :props="props">
             {{ props.row.data }}
           </q-td>
@@ -65,25 +71,26 @@
 <script setup>
 import versoes from "src/pages/version/components/version";
 import version from "../../package.json";
+
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 
 const columns = [
   {
-    name: "versao",
+    name:  "versao",
     label: "Versão do sistema",
     field: "versao",
     align: "center",
     style: "width: 5%; ",
   },
   {
-    name: "atualizacoes",
+    name:  "atualizacoes",
     label: "Atualizações",
     field: "atualizacoes",
     align: "left",
     style: "width: 75%;",
   },
   {
-    name: "data",
+    name:  "data",
     label: "Data da atualização",
     field: "data",
     align: "center",

+ 1 - 0
src/pages/WelcomePage.vue

@@ -1,6 +1,7 @@
 <template>
   <div class="flex column items-center justify-center q-gutter-y-md fullscreen">
     <div class="text-h4 text-primary text-weight-bold">Olá, bem-vindo</div>
+
     <q-img src="images/kizzo_logo_secondary.svg" width="500px" />
   </div>
 </template>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 151 - 567
src/pages/dashboard/DashboardPage.vue


+ 229 - 0
src/pages/dashboard/components/DashboardAvailabilityPanel.vue

@@ -0,0 +1,229 @@
+<template>
+  <q-card
+    class="panel-card panel-card--soft"
+    flat
+  >
+    <div class="panel-title">Disponibilidade do Período</div>
+
+    <div class="availability-list">
+      <div
+        v-for="item in decoratedItems"
+        :key="item.label"
+        class="availability-item"
+      >
+        <div class="availability-track">
+          <div
+            class="availability-bar"
+            :style="{
+              backgroundColor: item.color,
+            }"
+          >
+            <div class="availability-track-meta" :style="{ color: item.textColor }">
+              <span class="availability-bar-label">{{ item.label }}</span>
+
+              <span class="availability-bar-value-wrap">
+                <span
+                  class="availability-bar-value-dot"
+                  :style="{ backgroundColor: item.textColor }"
+                />
+
+                <span class="availability-bar-value">{{ item.valueLabel }}</span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="availability-total-track">
+      <div
+        v-for="item in items"
+        :key="`${item.label}-total`"
+        :style="{
+          width: `${item.percentage}%`, backgroundColor: item.color,
+        }"
+        class="availability-total-segment"
+      />
+    </div>
+
+    <div class="availability-total">
+      {{ totalLabel }}
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { formatInteger } from "src/helpers/utils";
+
+const props = defineProps({
+  isAllPropertiesSelected: {
+    type:    Boolean,
+    default: false,
+  },
+  items: {
+    type:    Array,
+    default: () => [],
+  },
+  totalCapacityDays: {
+    type:    Number,
+    default: 0,
+  },
+});
+
+const totalLabel = computed(() =>
+  props.isAllPropertiesSelected
+    ? "Total: 100% do período consolidado"
+    : `Total: ${formatInteger(props.totalCapacityDays)} dias no período`,
+);
+
+const clampChannel = (value) => Math.max(0, Math.min(255, value));
+
+const darkenColor = (hexColor, amount = 0.42) => {
+  const hex = String(hexColor ?? "").replace("#", "");
+
+  if (hex.length !== 6) {
+    return "#173235";
+  }
+
+  const channels = [0, 2, 4].map((start) => Number.parseInt(hex.slice(start, start + 2), 16));
+  const darkened = channels.map((channel) => clampChannel(Math.round(channel * (1 - amount))));
+
+  return `#${darkened.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
+};
+
+const decoratedItems = computed(() =>
+  props.items.map((item) => ({
+    ...item,
+    textColor: darkenColor(item.color),
+  })),
+);
+</script>
+
+<style scoped lang="scss">
+.panel-card {
+  padding:        18px;
+  border-radius:  14px;
+  background:     #ffffff;
+  border:         1px solid #d9e3e7;
+  min-height:     300px;
+  vertical-align: middle;
+}
+
+.panel-card--soft {
+  background: #f0f3f5;
+}
+
+.panel-title {
+  margin-bottom: 16px;
+  font-size:     19px;
+  font-weight:   400;
+  color:         #08514c;
+}
+
+.availability-list {
+  display:        flex;
+  flex-direction: column;
+  gap:            16px;
+}
+
+.availability-item {
+  display:        flex;
+  flex-direction: column;
+  gap:            10px;
+}
+
+.availability-track {
+  position:       relative;
+  width:          100%;
+  min-height:     42px;
+  overflow:       hidden;
+  background:     #dde5e8;
+  vertical-align: middle;
+}
+
+.availability-bar {
+  width:          100%;
+  min-height:     42px;
+  vertical-align: middle;
+}
+
+.availability-track-meta {
+  display:         flex;
+  align-items:     center;
+  justify-content: space-between;
+  gap:             12px;
+  width:           100%;
+  min-height:      42px;
+  padding:         0 14px;
+  position:        relative;
+  z-index:         1;
+  color:           #173235;
+  font-size:       13px;
+  font-weight:     700;
+  white-space:     nowrap;
+  vertical-align:  middle;
+}
+
+.availability-bar-label {
+  text-align:     left;
+  vertical-align: middle;
+}
+
+.availability-bar-value {
+  text-align:     right;
+  vertical-align: middle;
+}
+
+.availability-bar-value-wrap {
+  display:         inline-flex;
+  align-items:     center;
+  justify-content: flex-end;
+  gap:             8px;
+  vertical-align:  middle;
+}
+
+.availability-bar-value-dot {
+  display:        inline-flex;
+  width:          8px;
+  height:         8px;
+  border-radius:  50%;
+  vertical-align: middle;
+}
+
+.availability-total-track {
+  display:        flex;
+  width:          100%;
+  height:         18px;
+  margin-top:     18px;
+  overflow:       hidden;
+  background:     #dde5e8;
+  vertical-align: middle;
+}
+
+.availability-total-segment {
+  height:         100%;
+  min-width:      0;
+  vertical-align: middle;
+}
+
+.availability-total {
+  margin-top: 18px;
+  color:      #657177;
+  font-size:  14px;
+  text-align: center;
+}
+
+@media (max-width: 640px) {
+  .availability-bar {
+    min-height: 42px;
+  }
+
+  .availability-track-meta {
+    gap:        8px;
+    min-height: 42px;
+    padding:    0 12px;
+    font-size:  12px;
+  }
+}
+</style>

+ 188 - 0
src/pages/dashboard/components/DashboardChannelsPanel.vue

@@ -0,0 +1,188 @@
+<template>
+  <q-card class="panel-card panel-card--soft panel-card--channels" flat>
+    <div class="panel-title">Canais de Aquisição</div>
+
+    <div class="channel-chart-wrap">
+      <div class="channel-chart-box">
+        <Pie :data="chartData" :options="chartOptions" />
+      </div>
+    </div>
+
+    <div class="channel-legend">
+      <div
+        v-for="channel in legendItems"
+        :key="channel.key"
+        class="channel-legend-item"
+      >
+        <span class="channel-dot" :style="{ backgroundColor: channel.color }" />
+
+        <span class="channel-name">{{ channel.label }}</span>
+
+        <span class="channel-total">
+          {{ formatInteger(channel.reservations_count) }} reservas
+        </span>
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { Pie } from "vue-chartjs";
+import { formatInteger } from "src/helpers/utils";
+
+const props = defineProps({
+  channels: {
+    type: Array, default: () => [],
+  },
+});
+
+const channelPalette = Object.freeze([
+  "#FF3B30",
+  "#00A6FB",
+  "#FFC400",
+  "#34C759",
+  "#0052FF",
+  "#FF7A00",
+  "#5856D6",
+  "#00C7BE",
+  "#E53935",
+  "#1D4ED8",
+]);
+
+const normalize = (channel) => {
+  const value = String(channel ?? "").toLowerCase();
+
+  if (value.includes("airbnb")) {
+    return "airbnb";
+  }
+
+  if (value.includes("booking")) {
+    return "booking";
+  }
+
+  if (
+    value.includes("direto")
+    || value.includes("direct")
+    || value.includes("site")
+    || value.includes("whatsapp")
+    || value.includes("instagram")
+  ) {
+    return "direto";
+  }
+
+  if (value.includes("vrbo")) {
+    return "vrbo";
+  }
+
+  return "outros";
+};
+
+const buildFallbackColor = (index) => {
+  const hue = (index * 47) % 360;
+
+  return `hsl(${hue} 84% 52%)`;
+};
+
+const resolveChannelColor = (index) =>
+  channelPalette[index] ?? buildFallbackColor(index);
+
+const legendItems = computed(() =>
+  props.channels.map((c, index) => ({
+    key:                `${normalize(c.channel)}-${index}`,
+    label:              c.channel,
+    reservations_count: Number(c.reservations_count ?? 0),
+    color:              resolveChannelColor(index),
+  })),
+);
+
+const hasChannels = computed(() => legendItems.value.length > 0);
+
+const chartData = computed(() => ({
+  labels: hasChannels.value
+    ? legendItems.value.map((item) => item.label)
+    : ["Sem dados"],
+  datasets: [
+    {
+      data: hasChannels.value
+        ? legendItems.value.map((item) => item.reservations_count)
+        : [1],
+      backgroundColor: hasChannels.value
+        ? legendItems.value.map((item) => item.color)
+        : ["#D9E3E7"],
+      borderColor: "transparent",
+      borderWidth: 0,
+    },
+  ],
+}));
+
+const chartOptions = computed(() => ({
+  responsive: true,
+  maintainAspectRatio: false,
+  plugins: {
+    legend: { display: false },
+    tooltip: { enabled: false },
+  },
+}));
+</script>
+
+<style scoped>
+.panel-title {
+  margin-bottom: 16px;
+  font-size:     19px;
+  font-weight:   400;
+  color:         #08514c;
+}
+
+.channel-chart-wrap {
+  display:         flex;
+  justify-content: center;
+  padding:         8px 0 16px;
+}
+
+.channel-chart-box {
+  width:         min(120px, 100%);
+  height:        120px;
+  margin-bottom: 74px;
+}
+
+.channel-legend {
+  display: grid;
+
+  grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
+
+  gap:           14px;
+  justify-items: center;
+  text-align:    center;
+}
+
+.channel-legend-item {
+  display:        flex;
+  flex-direction: column;
+  align-items:    center;
+  gap:            4px;
+  vertical-align: middle;
+}
+
+.channel-dot {
+  display:       inline-flex;
+  width:         10px;
+  height:        10px;
+  border-radius: 50%;
+}
+
+.channel-name {
+  display:     inline-flex;
+  align-items: center;
+  gap:         8px;
+  font-size:   15px;
+  font-weight: 600;
+  color:       #273136;
+}
+
+.channel-total {
+  font-size:   14px;
+  font-weight: 400;
+  color:       #5f6d73;
+}
+</style>

+ 180 - 0
src/pages/dashboard/components/DashboardFiltersBar.vue

@@ -0,0 +1,180 @@
+<template>
+  <div class="dashboard-header">
+    <div class="dashboard-title">Dashboard</div>
+
+    <div class="dashboard-filters">
+      <q-select
+        :model-value="selectedPropertyOption"
+        :options="propertyOptions"
+        class="dashboard-filter dashboard-filter--property"
+        dense
+        label="Imóveis"
+        option-label="label"
+        outlined
+        @update:model-value="$emit('update:property', $event)"
+      />
+
+      <q-select
+        :model-value="selectedMonth"
+        :options="monthOptions"
+        class="dashboard-filter dashboard-filter--month"
+        dense
+        emit-value
+        label="Mês"
+        map-options
+        option-label="label"
+        option-value="value"
+        outlined
+        @update:model-value="$emit('update:month', $event)"
+      >
+        <template #append>
+          <q-icon name="mdi-calendar-blank" />
+        </template>
+      </q-select>
+
+      <q-select
+        :model-value="selectedYear"
+        :options="yearOptions"
+        class="dashboard-filter dashboard-filter--year"
+        dense
+        emit-value
+        label="Ano"
+        map-options
+        option-label="label"
+        option-value="value"
+        outlined
+        @update:model-value="$emit('update:year', $event)"
+      />
+
+      <q-btn-dropdown
+        class="dashboard-export-btn"
+        color="primary"
+        label="Exportar"
+        no-caps
+        unelevated
+        :disable="loading || exporting || !canExportReport"
+        :loading="exporting"
+      >
+        <q-list dense>
+          <q-item v-close-popup clickable @click="$emit('export', 'xlsx')">
+            <q-item-section>XLSX</q-item-section>
+          </q-item>
+
+          <q-item v-close-popup clickable @click="$emit('export', 'pdf')">
+            <q-item-section>PDF</q-item-section>
+          </q-item>
+        </q-list>
+      </q-btn-dropdown>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  canExportReport: {
+    type:    Boolean,
+    default: false,
+  },
+  exporting: {
+    type:    Boolean,
+    default: false,
+  },
+  loading: {
+    type:    Boolean,
+    default: false,
+  },
+  monthOptions: {
+    type:    Array,
+    default: () => [],
+  },
+  propertyOptions: {
+    type:    Array,
+    default: () => [],
+  },
+  selectedMonth: {
+    type:    Number,
+    default: null,
+  },
+  selectedPropertyOption: {
+    type:    Object,
+    default: null,
+  },
+  selectedYear: {
+    type:    Number,
+    default: null,
+  },
+  yearOptions: {
+    type:    Array,
+    default: () => [],
+  },
+});
+
+defineEmits([
+  "export",
+  "update:month",
+  "update:property",
+  "update:year",
+]);
+</script>
+
+<style scoped lang="scss">
+.dashboard-header {
+  display:         flex;
+  align-items:     center;
+  justify-content: space-between;
+  gap:             16px;
+  flex-wrap:       wrap;
+  vertical-align:  middle;
+}
+
+.dashboard-title {
+  font-size:   28px;
+  font-weight: 700;
+  color:       #202427;
+  line-height: 1.1;
+}
+
+.dashboard-filters {
+  display:         flex;
+  align-items:     center;
+  justify-content: flex-end;
+  gap:             12px;
+  flex-wrap:       wrap;
+  margin-left:     auto;
+  vertical-align:  middle;
+}
+
+.dashboard-filter {
+  min-width: 120px;
+}
+
+.dashboard-filter--property {
+  min-width: 220px;
+}
+
+.dashboard-filter--month,
+.dashboard-filter--year {
+  min-width: 110px;
+}
+
+.dashboard-export-btn {
+  height:         40px;
+  vertical-align: middle;
+}
+
+@media (max-width: 640px) {
+  .dashboard-filters {
+    width:           100%;
+    margin-left:     0;
+    justify-content: stretch;
+  }
+
+  .dashboard-filter,
+  .dashboard-filter--property,
+  .dashboard-filter--month,
+  .dashboard-filter--year {
+    width:     100%;
+    min-width: 100%;
+  }
+}
+</style>

+ 28 - 29
src/pages/dashboard/components/DashboardMetricCard.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-card class="metric-card" flat>
+  <q-card :class="['metric-card', `metric-card--${variant}`]" flat>
     <span class="metric-label">
       {{ label }}
     </span>
@@ -16,50 +16,49 @@
 
 <script setup>
 defineProps({
-  label: {
-    type: String,
-    required: true,
-  },
-  value: {
-    type: String,
-    required: true,
-  },
-  caption: {
-    type: String,
-    default: "",
-  },
+  caption: { type: String, default:  "" },
+  label:   { type: String, required: true },
+  value:   { type: String, required: true },
+  variant: { type: String, default: "primary" },
 });
 </script>
 
 <style scoped lang="scss">
 .metric-card {
-  display: flex;
-  flex-direction: column;
+  display:         flex;
+  flex-direction:  column;
   justify-content: space-between;
-  gap: 6px;
-  min-height: 106px;
-  padding: 16px;
-  border: 1px solid #d9e3e7;
+  gap:             6px;
+  min-height:      106px;
+  padding:         16px;
+  border:          1px solid #d9e3e7;
+  border-radius:   12px;
+  background:      #f0f3f5;
+  vertical-align:  middle;
+}
+
+.metric-card--primary {
   border-top: 3px solid #b7f35a;
-  border-radius: 12px;
-  background: #f0f3f5;
-  vertical-align: middle;
+}
+
+.metric-card--secondary {
+  border-top: 3px solid #84A8A6;
 }
 
 .metric-label {
-  font-size: 12px;
-  font-weight: 600;
-  color: #657177;
+  font-size:   19px;
+  font-weight: 400;
+  color:       #4D4E4E;
 }
 
 .metric-value {
-  font-size: 26px;
+  font-size:   24px;
   line-height: 1.15;
-  color: #1d2327;
+  color:       #111212;
 }
 
 .metric-caption {
-  font-size: 11px;
-  color: #7b878c;
+  font-size: 14px;
+  color:     #4D4E4E;
 }
 </style>

+ 17 - 14
src/pages/dashboard/components/DashboardPayoutTable.vue

@@ -32,22 +32,21 @@
 <script setup>
 defineProps({
   rows: {
-    type: Array,
-    default: () => [],
+    type: Array, default: () => [],
   },
   totalReservations: {
-    type: Number,
+    type:    Number,
     default: 0,
   },
   totalValue: {
-    type: Number,
+    type:    Number,
     default: 0,
   },
 });
 
 const formatCurrency = (value) => {
   return new Intl.NumberFormat("pt-BR", {
-    style: "currency",
+    style:    "currency",
     currency: "BRL",
     minimumFractionDigits: 2,
   }).format(Number(value ?? 0));
@@ -64,34 +63,38 @@ const formatInteger = (value) => {
 }
 
 .payout-table {
-  width: 100%;
+  width:           100%;
   border-collapse: collapse;
 }
 
 .payout-table th,
 .payout-table td {
-  padding: 10px 0;
-  border-bottom: 1px solid rgba(8, 81, 76, 0.1);
-  color: #273136;
-  text-align: left;
+  padding:        10px 16px;
+  border-bottom:  1px solid rgba(8, 81, 76, 0.1);
+  color:          #273136;
+  text-align:     left;
   vertical-align: middle;
 }
 
 .payout-table th:nth-child(2),
+.payout-table td:nth-child(2) {
+  text-align: center;
+}
+
 .payout-table th:nth-child(3),
-.payout-table td:nth-child(2),
 .payout-table td:nth-child(3) {
   text-align: right;
 }
 
 .payout-table th {
-  font-size: 12px;
+  font-size:   17px !important;
   font-weight: 700;
-  color: #657177;
+  color:       #232323;
 }
 
 .payout-total-row td {
-  font-weight: 700;
+  font-size:     17px !important;
+  font-weight:   700;
   border-bottom: 0;
 }
 </style>

+ 51 - 0
src/pages/dashboard/components/DashboardRevenuePanel.vue

@@ -0,0 +1,51 @@
+<template>
+  <q-card class="panel-card panel-card--soft" flat>
+    <div class="panel-title">Evolução do Faturamento</div>
+
+    <div class="line-chart-wrap">
+      <Line :data="chartData" :options="chartOptions" />
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { Line } from "vue-chartjs";
+
+defineProps({
+  chartData: {
+    type:     Object,
+    required: true,
+  },
+  chartOptions: {
+    type:     Object,
+    required: true,
+  },
+});
+</script>
+
+<style scoped lang="scss">
+.panel-card {
+  padding:        18px;
+  border-radius:  14px;
+  background:     #ffffff;
+  border:         1px solid #d9e3e7;
+  min-height:     300px;
+  vertical-align: middle;
+}
+
+.panel-title {
+  margin-bottom: 16px;
+  font-size:     19px;
+  font-weight:   400;
+  color:         #08514c;
+}
+
+.panel-card--soft {
+  background: #f0f3f5;
+}
+
+.line-chart-wrap {
+  height:         235px;
+  vertical-align: middle;
+}
+</style>

+ 0 - 42
src/pages/settings/SettingsPage.vue

@@ -1,42 +0,0 @@
-<template>
-  <q-page class="q-pa-md">
-    <SettingsTabsHeader />
-
-    <q-banner
-      class="bg-grey-2 text-primary"
-      inline-actions
-      rounded
-      style="border: 1px dashed rgba(14, 52, 91, 0.18)"
-    >
-      Redirecionando para a primeira aba disponível.
-    </q-banner>
-  </q-page>
-</template>
-
-<script setup>
-import { onMounted } from "vue";
-import { permissionStore } from "src/stores/permission";
-import { useRouter } from "vue-router";
-
-import SettingsTabsHeader from "./components/SettingsTabsHeader.vue";
-
-const router = useRouter();
-
-const { getAccess } = permissionStore();
-
-const redirectToFirstAllowedTab = () => {
-  if (getAccess("config.user", "view")) {
-    router.replace({ name: "SettingsUsersPage" });
-
-    return;
-  }
-
-  if (getAccess("config.permission", "view")) {
-    router.replace({ name: "SettingsUserTypesPage" });
-  }
-};
-
-onMounted(() => {
-  redirectToFirstAllowedTab();
-});
-</script>

+ 0 - 189
src/pages/settings/SettingsUserActionPage.vue

@@ -1,189 +0,0 @@
-<template>
-  <q-page class="q-pa-md">
-    <SettingsTabsHeader />
-
-    <q-card bordered class="settings-card" flat>
-      <q-card-section class="q-pb-none">
-        <div class="text-h6 text-weight-bold">
-          {{
-            isEditMode
-              ? $t("settings.users.edit_title")
-              : $t("settings.users.create_title")
-          }}
-        </div>
-
-        <div class="text-grey-7 q-mt-xs">
-          {{ $t("settings.users.form_description") }}
-        </div>
-      </q-card-section>
-
-      <q-card-section class="q-pt-lg">
-        <div class="row q-col-gutter-lg">
-          <q-input
-            v-model="form.name"
-            class="col-12 col-md-6"
-            :error="!!serverErrors.name"
-            :error-message="serverErrors.name"
-            :label="$t('common.terms.name')"
-          />
-
-          <q-input
-            v-model="form.email"
-            class="col-12 col-md-6"
-            type="email"
-            :error="!!serverErrors.email"
-            :error-message="serverErrors.email"
-            :label="$t('common.terms.email')"
-          />
-
-          <q-select
-            v-model="form.user_type_id"
-            class="col-12 col-md-6"
-            emit-value
-            map-options
-            :error="!!serverErrors.user_type_id"
-            :error-message="serverErrors.user_type_id"
-            :label="$t('settings.user_types.single')"
-            :options="userTypeOptions"
-          />
-
-          <q-input
-            v-model="form.password"
-            class="col-12 col-md-6"
-            type="password"
-            :error="!!serverErrors.password"
-            :error-message="serverErrors.password"
-            :hint="
-              isEditMode ? $t('settings.users.password_optional_hint') : ''
-            "
-            :label="$t('common.terms.password')"
-          />
-        </div>
-      </q-card-section>
-
-      <q-separator />
-
-      <q-card-actions align="right" class="q-pa-lg">
-        <q-btn
-          color="primary"
-          outline
-          padding="10px 24px"
-          :label="$t('common.actions.cancel')"
-          @click="router.push({ name: 'SettingsUsersPage' })"
-        />
-
-        <q-btn
-          class="btn-custom-default"
-          color="secondary"
-          padding="10px 28px"
-          :disable="isSaveDisabled"
-          :loading="loading"
-          @click="onSubmit"
-        >
-          <span class="text-primary">{{ $t("common.actions.save") }}</span>
-        </q-btn>
-      </q-card-actions>
-    </q-card>
-  </q-page>
-</template>
-
-<script setup>
-import { computed, onMounted, ref } from "vue";
-import { createUser, getUserById, updateUser, userTypes } from "src/api/user";
-import { normalizeUserTypeOptions } from "./utils";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
-import { useI18n } from "vue-i18n";
-import { useRoute, useRouter } from "vue-router";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-
-import SettingsTabsHeader from "./components/SettingsTabsHeader.vue";
-
-const route  = useRoute();
-const router = useRouter();
-
-const { t } = useI18n();
-
-const { execute, loading, serverErrors } = useSubmitHandler(() => {
-  router.push({ name: "SettingsUsersPage" });
-});
-
-const { form, getUpdatedFields, hasUpdatedFields, setUpdateFormAsOriginal } =
-  useFormUpdateTracker({
-    name:         "",
-    email:        "",
-    password:     "",
-    user_type_id: null,
-  });
-
-const userTypeOptions = ref([]);
-
-const isEditMode = computed(() => !!route.params.id);
-
-const isSaveDisabled = computed(() => {
-  if (!isEditMode.value) {
-    return !form.name || !form.email || !form.password || !form.user_type_id;
-  }
-
-  return !hasUpdatedFields.value;
-});
-
-const fetchUserTypeOptions = async () => {
-  const response = await userTypes();
-  userTypeOptions.value = normalizeUserTypeOptions(response, t);
-};
-
-const fetchUser = async () => {
-  const response = await getUserById(route.params.id);
-
-  form.name         = response.name  || "";
-  form.email        = response.email || "";
-  form.password     = "";
-  form.user_type_id = Number(response.user_type?.value || response.user_type_id);
-
-  setUpdateFormAsOriginal();
-};
-
-const sanitizePayload = (payload) => {
-  const nextPayload = { ...payload };
-
-  Object.keys(nextPayload).forEach((key) => {
-    if (
-      nextPayload[key] === null      ||
-      nextPayload[key] === undefined ||
-      nextPayload[key] === ""
-    ) {
-      delete nextPayload[key];
-    }
-  });
-
-  return nextPayload;
-};
-
-const onSubmit = async () => {
-  const payload = sanitizePayload(
-    isEditMode.value ? getUpdatedFields.value : form,
-  );
-
-  await execute(async () => {
-    if (isEditMode.value) {
-      return await updateUser(payload, route.params.id);
-    }
-
-    return await createUser(payload);
-  });
-};
-
-onMounted(async () => {
-  await fetchUserTypeOptions();
-
-  if (isEditMode.value) {
-    await fetchUser();
-  }
-});
-</script>
-
-<style scoped lang="scss">
-.settings-card {
-  border-radius: 18px;
-}
-</style>

+ 0 - 319
src/pages/settings/SettingsUserTypeActionPage.vue

@@ -1,319 +0,0 @@
-<template>
-  <q-page class="q-pa-md">
-    <SettingsTabsHeader />
-
-    <q-card flat bordered class="settings-card">
-      <q-card-section class="q-pb-none">
-        <div class="text-h6 text-weight-bold">
-          {{ pageTitle }}
-        </div>
-
-        <div class="text-grey-7 q-mt-xs">
-          {{ pageDescription }}
-        </div>
-      </q-card-section>
-
-      <q-card-section v-if="isCreateMode" class="q-pt-lg">
-        <q-banner
-          v-if="readonlyBannerMessage"
-          class="bg-blue-1 text-primary q-mb-md"
-          rounded
-        >
-          {{ readonlyBannerMessage }}
-        </q-banner>
-
-        <div class="row q-col-gutter-lg q-mt-md">
-          <q-input
-            v-model="draftUserType.description"
-            class="col-12 col-md-6"
-            :disable="isReadOnly"
-            :error="!!serverErrors.description"
-            :error-message="serverErrors.description"
-            :label="$t('settings.user_types.description')"
-          />
-        </div>
-      </q-card-section>
-
-      <q-card-section v-else class="q-pt-lg">
-        <q-banner
-          v-if="readonlyBannerMessage"
-          class="bg-blue-1 text-primary q-mb-md"
-          rounded
-        >
-          {{ readonlyBannerMessage }}
-        </q-banner>
-
-        <div class="row items-center q-col-gutter-md q-mb-md">
-          <div class="col-auto">
-            <q-chip color="secondary" text-color="primary" icon="mdi-tag">
-              {{ selectedUserTypeLabel }}
-            </q-chip>
-          </div>
-
-          <div class="col-auto">
-            <q-chip outline color="primary">
-              {{
-                $t("settings.user_types.selected_permissions", {
-                  count: ticked.length,
-                })
-              }}
-            </q-chip>
-          </div>
-        </div>
-      </q-card-section>
-
-      <q-card-section class="q-pt-none q-pb-lg">
-        <div v-if="isCreateMode" class="row items-center q-col-gutter-md q-mb-md">
-          <div class="col-auto">
-            <q-chip outline color="primary">
-              {{
-                $t("settings.user_types.selected_permissions", {
-                  count: ticked.length,
-                })
-              }}
-            </q-chip>
-          </div>
-        </div>
-
-        <q-tree
-          v-if="treeNodes.length"
-          v-model:expanded="expanded"
-          class="permission-tree"
-          node-key="id"
-          tick-strategy="leaf"
-          :nodes="treeNodes"
-          :ticked="ticked"
-          @update:ticked="onTickedUpdate"
-        >
-          <template #default-header="props">
-            <div class="row items-center no-wrap full-width">
-              <div class="q-ml-sm">
-                <div class="text-body2 text-weight-medium">
-                  {{ props.node.label }}
-                </div>
-              </div>
-            </div>
-          </template>
-        </q-tree>
-
-        <q-inner-loading :showing="loadingTree" color="primary" />
-      </q-card-section>
-
-      <q-separator />
-
-      <q-card-actions align="right" class="q-pa-lg">
-        <q-btn
-          color="primary"
-          outline
-          padding="10px 24px"
-          :label="$t('common.actions.back')"
-          @click="router.push({ name: 'SettingsUserTypesPage' })"
-        />
-
-        <q-btn
-          class="btn-custom-default"
-          color="secondary"
-          padding="10px 28px"
-          :disable="saveDisabled"
-          :loading="loading"
-          @click="onSubmit"
-        >
-          <span class="text-primary">{{ $t("common.actions.save") }}</span>
-        </q-btn>
-      </q-card-actions>
-    </q-card>
-  </q-page>
-</template>
-
-<script setup>
-import {
-  buildPermissionPayloadFromTicked,
-  buildPermissionTreeNodes,
-  buildTickedKeys,
-  isLockedUserTypePermission,
-  normalizeUserTypeOptions
-} from "./utils";
-
-import { computed, onMounted, ref } from "vue";
-import { createUserType, userTypes } from "src/api/user";
-import { getPermissionCatalog, getPermissionsByUserType, updatePermissionsByUserType } from "src/api/permission";
-import { useI18n } from "vue-i18n";
-import { useRoute, useRouter } from "vue-router";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-
-import SettingsTabsHeader from "./components/SettingsTabsHeader.vue";
-
-const route  = useRoute();
-const router = useRouter();
-
-const { t } = useI18n();
-
-const catalog         = ref([]);
-const expanded        = ref([]);
-const treeNodes       = ref([]);
-const ticked          = ref([]);
-const originalTicked  = ref([]);
-const loadingTree     = ref(false);
-const userTypeOptions = ref([]);
-
-const draftUserType = ref({
-  description: "",
-});
-
-const currentUserTypeId = computed(() => Number(route.params.userTypeId || 0));
-
-const currentUserType = computed(() =>
-  userTypeOptions.value.find((option) => option.id === currentUserTypeId.value) || null,
-);
-
-const hasChanges = computed(() => {
-  return JSON.stringify([...ticked.value].sort()) !==
-    JSON.stringify([...originalTicked.value].sort());
-});
-
-const isCreateMode = computed(() => !route.params.userTypeId);
-
-
-const isProtectedUserType = computed(() =>
-  !isCreateMode.value && isLockedUserTypePermission(currentUserType.value),
-);
-
-const isReadOnly = computed(() => isProtectedUserType.value);
-
-const pageTitle = computed(() => {
-  if (isCreateMode.value) {
-    return t("settings.user_types.create_title");
-  }
-
-  return t("settings.user_types.edit_title", {
-    userType: selectedUserTypeLabel.value,
-  });
-});
-
-const pageDescription = computed(() => {
-  if (isCreateMode.value) {
-    return t("settings.user_types.create_description");
-  }
-
-  return t("settings.user_types.edit_description");
-});
-
-const readonlyBannerMessage = computed(() => {
-  if (isProtectedUserType.value) {
-    return t("settings.user_types.readonly_banner");
-  }
-
-  return "";
-});
-
-const saveDisabled = computed(() => {
-  if (isCreateMode.value) {
-    return isReadOnly.value || !draftUserType.value.description.trim();
-  }
-
-  return isReadOnly.value || !hasChanges.value;
-});
-
-const selectedUserTypeLabel = computed(() => {
-  return currentUserType.value?.label || "";
-});
-
-
-const { execute, loading, serverErrors } = useSubmitHandler((response) => {
-  if (isCreateMode.value) {
-    router.push({
-      name: "SettingsUserTypeEditPage", params: { userTypeId: response.id },
-    });
-
-    return;
-  }
-
-  originalTicked.value = [...ticked.value];
-});
-
-const fetchPermissionTree = async () => {
-  loadingTree.value = true;
-
-  try {
-    const [catalogResponse, assignedResponse] = await Promise.all([
-      getPermissionCatalog(),
-      isCreateMode.value
-        ? Promise.resolve([])
-        : getPermissionsByUserType(currentUserTypeId.value),
-    ]);
-
-    catalog.value   = catalogResponse;
-    treeNodes.value = buildPermissionTreeNodes(
-      catalogResponse,
-      assignedResponse,
-      t,
-      isReadOnly.value,
-    );
-    ticked.value    = buildTickedKeys(catalogResponse, assignedResponse);
-    expanded.value  = [];
-
-    originalTicked.value = [...ticked.value];
-  } finally {
-    loadingTree.value = false;
-  }
-};
-
-const fetchUserTypeOptions = async () => {
-  const response = await userTypes();
-
-  userTypeOptions.value = normalizeUserTypeOptions(response, t);
-};
-
-const onSubmit = async () => {
-  if (isCreateMode.value) {
-    await execute(async () => {
-      const createdUserType = await createUserType({
-        description: draftUserType.value.description.trim().toLowerCase(),
-      });
-
-      const permissions = buildPermissionPayloadFromTicked(ticked.value);
-
-      if (permissions.length) {
-        await updatePermissionsByUserType(createdUserType.id, permissions);
-      }
-
-      return createdUserType;
-    });
-
-    return;
-  }
-
-  const permissions = buildPermissionPayloadFromTicked(ticked.value);
-
-  await execute(async () => {
-    return await updatePermissionsByUserType(currentUserTypeId.value, permissions);
-  });
-};
-
-const onTickedUpdate = (nextTicked) => {
-  if (isReadOnly.value) {
-    return;
-  }
-
-  ticked.value = [...nextTicked];
-};
-
-onMounted(async () => {
-  await fetchUserTypeOptions();
-  await fetchPermissionTree();
-});
-</script>
-
-<style scoped lang="scss">
-.settings-card {
-  border-radius: 18px;
-}
-
-.permission-tree {
-  border:        1px solid rgba(14, 52, 91, 0.12);
-  border-radius: 16px;
-  padding:       16px;
-  min-height:    420px;
-}
-
-</style>

+ 0 - 148
src/pages/settings/SettingsUserTypesPage.vue

@@ -1,148 +0,0 @@
-<template>
-  <q-page class="q-pa-md">
-    <SettingsTabsHeader />
-
-    <DefaultTable
-      :add-item="canCreateUserType"
-      :add-item-route="'SettingsUserTypeCreatePage'"
-      :api-call="fetchUserTypesRows"
-      :columns="columns"
-      open-item
-      @on-row-click="onRowClick"
-    >
-      <template #body-cell-permissions="{ row }">
-        <q-td align="center">
-          <q-chip
-            v-if="row.permissionsCount"
-            color="primary"
-            dense
-            outline
-          >
-            {{ row.permissionsCount }}
-            <q-tooltip
-              class="bg-grey-2 text-grey-9 shadow-1 permissions-tooltip"
-            >
-              <div
-                v-for="permission in row.permissionsTooltipLines"
-                :key="permission"
-                class="q-mb-xs"
-              >
-                {{ permission }}
-              </div>
-            </q-tooltip>
-          </q-chip>
-
-          <span v-else>-</span>
-        </q-td>
-      </template>
-
-      <template #body-cell-locked="{ row }">
-        <q-td align="center">
-          <q-badge
-            text-color="white"
-            :color="row.locked ? 'blue-grey-8' : 'green-7'"
-            :label="
-              row.locked
-                ? $t('settings.user_types.locked')
-                : $t('settings.user_types.editable')
-            "
-          />
-        </q-td>
-      </template>
-
-      <template #body-cell-actions>
-        <q-td align="center">
-          <q-icon name="mdi-playlist-edit" size="md" />
-        </q-td>
-      </template>
-    </DefaultTable>
-  </q-page>
-</template>
-
-<script setup>
-import {
-  buildPermissionTooltipLines,
-  isLockedUserTypePermission,
-  normalizeUserTypeOptions,
-  translateUserTypeLabelUppercase
-} from "./utils";
-
-import { computed, ref } from "vue";
-import { getPermissionCatalog, getPermissionsByUserType } from "src/api/permission";
-import { permissionStore } from "src/stores/permission";
-import { useI18n } from "vue-i18n";
-import { useRouter } from "vue-router";
-import { userTypes } from "src/api/user";
-
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import SettingsTabsHeader from "./components/SettingsTabsHeader.vue";
-
-const router = useRouter();
-
-const { t } = useI18n();
-const { getAccess } = permissionStore();
-
-const canCreateUserType = computed(() =>
-  getAccess("config.permission", "add"),
-);
-
-const columns = ref([
-  { name: "id",    field: "id",    label: "ID",                            sortable: true, align: "left", style: "width: 80px" },
-  { name: "label", field: "label", label: t("settings.user_types.single"), sortable: true, align: "left" },
-
-  { name: "permissions", field: "permissions", label: t("settings.user_types.permissions"), align: "center" },
-  { name: "locked",      field: "locked",      label: t("common.terms.status"),             align: "center" },
-  { name: "actions",     field: "actions",     label: t("settings.user_types.actions"),     align: "center" },
-]);
-
-const fetchUserTypesRows = async () => {
-  const [response, permissionCatalog] = await Promise.all([
-    userTypes(),
-    getPermissionCatalog(),
-  ]);
-
-  const options = normalizeUserTypeOptions(response, t);
-
-  const permissionsByUserType = await Promise.all(
-    options.map(async (option) => ({
-      id: option.id, assignedPermissions: await getPermissionsByUserType(option.id),
-    })),
-  );
-
-  const assignedPermissionsMap = new Map(
-    permissionsByUserType.map((item) => [item.id, item.assignedPermissions]),
-  );
-
-  return options.map((option) => {
-    const assignedPermissions = assignedPermissionsMap.get(option.id) || [];
-
-    const permissionsTooltipLines = buildPermissionTooltipLines(
-      permissionCatalog,
-      assignedPermissions,
-      t,
-    );
-
-    return {
-      id: option.id, label: translateUserTypeLabelUppercase(option, t),
-
-      permissionsCount: permissionsTooltipLines.length,
-
-      permissionsTooltipLines,
-
-      locked: isLockedUserTypePermission(option),
-    };
-  });
-};
-
-const onRowClick = ({ row }) => {
-  router.push({
-    name: "SettingsUserTypeEditPage", params: { userTypeId: row.id },
-  });
-};
-</script>
-
-<style scoped lang="scss">
-.permissions-tooltip {
-  max-width: 420px;
-}
-</style>

+ 0 - 45
src/pages/settings/SettingsUsersPage.vue

@@ -1,45 +0,0 @@
-<template>
-  <q-page class="q-pa-md">
-    <SettingsTabsHeader />
-
-    <DefaultTable
-      open-item
-      :add-item-route="'SettingsUserCreatePage'"
-      :api-call="fetchUsers"
-      :columns="columns"
-      :open-item-route="'SettingsUserEditPage'"
-    >
-      <template #body-cell-actions>
-        <q-td align="center">
-          <q-icon name="mdi-playlist-edit" size="md" />
-        </q-td>
-      </template>
-    </DefaultTable>
-  </q-page>
-</template>
-
-<script setup>
-import { formatDateYMDtoDMY } from "src/helpers/utils";
-import { getUsers } from "src/api/user";
-import { ref } from "vue";
-import { translateUserTypeLabelUppercase } from "./utils";
-import { useI18n } from "vue-i18n";
-
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import SettingsTabsHeader from "./components/SettingsTabsHeader.vue";
-
-const { t } = useI18n();
-
-const columns = ref([
-  { name: "id",           field: "id",         label: "ID",                            align: "left",   sortable: true, style: "width: 80px" },
-  { name: "user_type_id", field: "user_type",  label: t("settings.user_types.single"), align: "left",   sortable: true, format: (value) => translateUserTypeLabelUppercase(value, t) },
-  { name: "name",         field: "name",       label: t("settings.users.single"),      align: "left",   sortable: true },
-  { name: "email",        field: "email",      label: "Email",                         align: "left",   sortable: true },
-  { name: "created_at",   field: "created_at", label: t("settings.users.created_at"),  align: "center", sortable: true, format: (value) => formatDateYMDtoDMY(value) },
-  { name: "actions",      field: "actions",    label: t("settings.users.actions"),     align: "center" },
-]);
-
-const fetchUsers = async () => {
-  return await getUsers();
-};
-</script>

+ 0 - 182
src/pages/settings/components/SettingsDialog.vue

@@ -1,182 +0,0 @@
-<template>
-  <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card
-      class="q-dialog-plugin overflow-hidden"
-      style="width: 100%; max-width: 1200px; height: 100%; max-height: 800px"
-    >
-      <DefaultDialogHeader title="Ajustes Usuário" @close="onDialogCancel" />
-
-      <div class="q-pa-md" style="height: 100%">
-        <div
-          class="text-center text-subtitle1 text-weight-bold text-primary"
-          style="
-            border:        1px solid rgba(0, 0, 0, 0.24);
-            width:         100%;
-            height:        100%;
-            max-height:    660px;
-            margin:        auto;
-            border-radius: 8px;
-          "
-        >
-          <div class="q-my-md">Dados pessoais</div>
-
-          <div class="row q-col-gutter-sm q-pa-md">
-            <q-input
-              v-model="form.id"
-              class="col-2"
-              disable
-              label="N. Registro"
-            />
-
-            <q-input
-              v-model="form.name"
-              class="col-6"
-              label="Nome"
-              :rules="[inputRules.required]"
-            />
-
-            <q-input
-              v-model="form.document_number"
-              class="col-4"
-              disable
-              label="CPF"
-              :mask="masks.Brasil.cpf"
-              :rules="[inputRules.required]"
-            />
-
-            <DefaultPasswordInput
-              v-model="form.password"
-              class="col-3"
-              label="Senha"
-            />
-
-            <DefaultPasswordInput
-              v-model="form.password_confirmation"
-              class="col-3"
-              label="Confirmar senha"
-            />
-
-            <q-select
-              v-model="userTypeSelected"
-              class="col-3"
-              disable
-              label="Tipo de Usuario"
-              map-options
-              :options="userTypeSelect"
-              :rules="[inputRules.required]"
-            />
-          </div>
-        </div>
-
-        <q-card-actions align="right" class="q-mt-md q-pa-none">
-          <q-btn
-            color="primary"
-            label="Cancelar"
-            outline
-            padding="10px 30px"
-            style="border-radius: 8px"
-            @click="onDialogCancel"
-          />
-
-          <q-btn
-            color="primary"
-            label="Salvar"
-            padding="10px 40px"
-            style="border-radius: 8px"
-            :disable="!hasUpdatedFields"
-            :loading="loading"
-            @click="onSubmit"
-          />
-        </q-card-actions>
-      </div>
-    </q-card>
-  </q-dialog>
-</template>
-
-<script setup>
-import { computed, onMounted, ref } from "vue";
-import { getUserById, updateUser } from "src/api/user";
-import { useDialogPluginComponent } from "quasar";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
-import { useInputRules } from "src/composables/useInputRules";
-
-import masks from "src/helpers/masks";
-
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
-
-const { inputRules } = useInputRules();
-
-const { userId } = defineProps({
-  userId: { type: Number, default: null, required: true },
-});
-
-defineEmits([...useDialogPluginComponent.emits]);
-
-const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } = useDialogPluginComponent();
-
-const { form, hasUpdatedFields, setUpdateFormAsOriginal, getUpdatedFields } =
-  useFormUpdateTracker({
-    id:              null,
-    name:            null,
-    document_number: null,
-    password:        null,
-    user_type:       null,
-    password_confirmation: null,
-  });
-
-const userTypeSelected = ref(null);
-
-const loading = ref(false);
-
-const userTypeSelect = ref([
-  { label: "Administrador", value: 1 },
-  { label: "Convidado",     value: 2 },
-  { label: "Proprietario",  value: 3 },
-  { label: "Recepcionista", value: 4 },
-  { label: "Cliente",       value: 5 },
-  { label: "Prestador",     value: 6 },
-  { label: "Gerente",       value: 7 },
-  { label: "Agente",        value: 8 },
-]);
-
-const requiredInputs = computed(() => {
-  return form.name && form.document_number && form.user_type;
-});
-
-const fetchUser = async () => {
-  try {
-    const userData = await getUserById(userId);
-
-    Object.assign(form, userData);
-
-    setUpdateFormAsOriginal();
-
-    userTypeSelected.value = userData.user_type;
-  } catch (error) {
-    console.error("Falha ao buscar dados do usuário:", error);
-  }
-};
-
-const onSubmit = async () => {
-  try {
-    if (!requiredInputs.value) {
-      return;
-    }
-
-    loading.value = true;
-
-    const payload = getUpdatedFields.value;
-
-    await updateUser(payload, userId);
-
-    onDialogOK();
-  } catch (error) {
-    console.error(error);
-  } finally {
-    loading.value = false;
-  }
-};
-
-onMounted(fetchUser);
-</script>

+ 0 - 92
src/pages/settings/components/SettingsTabsHeader.vue

@@ -1,92 +0,0 @@
-<template>
-  <div class="q-mb-md">
-    <DefaultHeaderPage />
-
-    <q-tabs
-      v-model="currentTab"
-      align="left"
-      active-color="primary"
-      class="settings-tabs q-mt-lg"
-      indicator-color="transparent"
-      inline-label
-      no-caps
-    >
-      <q-tab
-        v-for="tabItem in tabsItems"
-        :key="tabItem.name"
-        class="settings-tab"
-        :class="{ hidden: tabItem.hide }"
-        :disable="tabItem.disable"
-        :label="tabItem.label"
-        :name="tabItem.name"
-      />
-    </q-tabs>
-  </div>
-</template>
-
-<script setup>
-import { computed } from "vue";
-import { permissionStore } from "src/stores/permission";
-import { useI18n } from "vue-i18n";
-import { useRoute, useRouter } from "vue-router";
-
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-
-const route  = useRoute();
-const router = useRouter();
-
-const { t } = useI18n();
-
-const { getAccess } = permissionStore();
-
-const tabRoutes = Object.freeze({
-  users: "SettingsUsersPage", user_types: "SettingsUserTypesPage",
-});
-
-const tabsItems = computed(() => [
-  { name: "users",      label: t("settings.tabs.users"),      hide: !getAccess("config.user", "view") },
-  { name: "user_types", label: t("settings.tabs.user_types"), hide: !getAccess("config.permission", "view") },
-]);
-
-const resolveTabByRoute = () => {
-  if (route.path.startsWith("/settings/user-types")) {
-    return "user_types";
-  }
-
-  return "users";
-};
-
-const currentTab = computed({
-  get() {
-    return resolveTabByRoute();
-  },
-  set(value) {
-    const routeName = tabRoutes[value];
-
-    if (routeName) {
-      router.push({ name: routeName });
-    }
-  },
-});
-</script>
-
-<style scoped lang="scss">
-.settings-tabs {
-  display:       inline-flex;
-  border-radius: 14px;
-  background:    rgba(14, 52, 91, 0.05);
-  padding:       4px;
-}
-
-.settings-tab {
-  min-height:    36px;
-  padding:       0 14px;
-  border-radius: 10px;
-}
-
-.settings-tab.q-tab--active {
-  background: white;
-  color:      var(--q-primary);
-  box-shadow: 0 8px 18px rgba(14, 52, 91, 0.14);
-}
-</style>

+ 0 - 217
src/pages/settings/utils.js

@@ -1,217 +0,0 @@
-export const LOCKED_PERMISSION_USER_TYPE_IDS = Object.freeze([1, 7]);
-
-export const LOCKED_PERMISSION_USER_TYPE_DESCRIPTIONS = Object.freeze(["admin", "manager"]);
-
-export const PERMISSION_FLAGS = Object.freeze([
-  { key: "read",   labelKey: "settings.permissions.read",   bit: 1 },
-  { key: "create", labelKey: "settings.permissions.create", bit: 2 },
-  { key: "edit",   labelKey: "settings.permissions.edit",   bit: 4 },
-  { key: "delete", labelKey: "settings.permissions.delete", bit: 8 },
-  { key: "print",  labelKey: "settings.permissions.print",  bit: 16 },
-  { key: "export", labelKey: "settings.permissions.export", bit: 32 },
-  { key: "import", labelKey: "settings.permissions.import", bit: 64 },
-  { key: "limit",  labelKey: "settings.permissions.limit",  bit: 128 },
-  { key: "menu",   labelKey: "settings.permissions.menu",   bit: 256 },
-]);
-
-const USER_TYPE_DESCRIPTION_KEY_MAP = Object.freeze({
-  admin:        "settings.user_types.types.admin",
-  guest:        "settings.user_types.types.guest",
-  owner:        "settings.user_types.types.owner",
-  receptionist: "settings.user_types.types.receptionist",
-  customer:     "settings.user_types.types.customer",
-  provider:     "settings.user_types.types.provider",
-  manager:      "settings.user_types.types.manager",
-  agent:        "settings.user_types.types.agent",
-});
-
-const capitalize = (value) => {
-  if (!value) {
-    return "";
-  }
-
-  return value.charAt(0).toUpperCase() + value.slice(1);
-};
-
-export const translateUserTypeLabel = (userType, t) => {
-  const description =
-    typeof userType === "object" && userType !== null
-      ? userType.description
-      : String(userType || "");
-
-  const translationKey = USER_TYPE_DESCRIPTION_KEY_MAP[description];
-
-  if (translationKey) {
-    return t(translationKey);
-  }
-
-  return capitalize(description);
-};
-
-export const translateUserTypeLabelUppercase = (userType, t) =>
-  translateUserTypeLabel(userType, t).toUpperCase();
-
-export const normalizeUserTypeOptions = (payload, t) => {
-  const items = Array.isArray(payload) ? payload : [];
-
-  return items
-    .map((item) => ({
-      label:       translateUserTypeLabel(item, t),
-      value:       Number(item.value ?? item.id),
-      description: item.description,
-      id:          Number(item.id ?? item.value),
-    }))
-    .sort((a, b) => a.value - b.value);
-};
-
-export const isLockedUserTypePermission = (userType) => {
-  if (typeof userType === "object" && userType !== null) {
-    const description = String(userType.description || "").toLowerCase();
-
-    if (!description) {
-      return false;
-    }
-
-    return LOCKED_PERMISSION_USER_TYPE_DESCRIPTIONS.includes(description);
-  }
-
-  return LOCKED_PERMISSION_USER_TYPE_IDS.includes(Number(userType));
-};
-
-const buildOperationNodeKey = (permissionId, flagKey) =>
-  `permission-${permissionId}-${flagKey}`;
-
-export const buildPermissionTreeNodes = (
-  catalog,
-  assignedPermissions = [],
-  t,
-  readOnly = false,
-) => {
-  const assignedByPermissionId = new Map(
-    assignedPermissions.map((permission) => [permission.permission_id, permission]),
-  );
-
-  const permissionsByParentId = catalog.reduce((accumulator, permission) => {
-    const parentId = permission.parent_id ?? null;
-
-    if (!accumulator.has(parentId)) {
-      accumulator.set(parentId, []);
-    }
-
-    accumulator.get(parentId).push(permission);
-
-    return accumulator;
-  }, new Map());
-
-  const buildNodes = (parentId = null) => {
-    const permissions = permissionsByParentId.get(parentId) || [];
-
-    return permissions.map((permission) => {
-      const assignedPermission = assignedByPermissionId.get(permission.id);
-      const permissionChildren = buildNodes(permission.id);
-
-      const operationChildren = PERMISSION_FLAGS.filter(
-        (flag) => (permission.bits & flag.bit) === flag.bit,
-      ).map((flag) => ({
-        id:         buildOperationNodeKey(permission.id, flag.key),
-        label:      t(flag.labelKey),
-        icon:       "mdi-circle-small",
-        disabled:   readOnly,
-        selectable: false,
-      }));
-
-      return {
-        id:           `scope-${permission.id}`,
-        label:        permission.description,
-        caption:      permission.scope,
-        icon:         "mdi-shield-key-outline",
-        selectable:   false,
-        assignedBits: assignedPermission?.bits ?? 0,
-
-        children: [...permissionChildren, ...operationChildren],
-      };
-    });
-  };
-
-  return buildNodes();
-};
-
-export const buildTickedKeys = (catalog, assignedPermissions = []) => {
-  const catalogById = new Map(catalog.map((permission) => [permission.id, permission]));
-
-  return assignedPermissions.flatMap((permission) => {
-    const catalogPermission = catalogById.get(permission.permission_id);
-
-    if (!catalogPermission) {
-      return [];
-    }
-
-    return PERMISSION_FLAGS.filter(
-      (flag) =>
-        (catalogPermission.bits & flag.bit) === flag.bit &&
-        (permission.bits & flag.bit) === flag.bit,
-    ).map((flag) => buildOperationNodeKey(permission.permission_id, flag.key));
-  });
-};
-
-export const buildPermissionPayloadFromTicked = (tickedKeys) => {
-  const permissionsById = new Map();
-
-  tickedKeys.forEach((key) => {
-    const match = key.match(/^permission-(\d+)-([a-z_]+)$/);
-
-    if (!match) {
-      return;
-    }
-
-    const permissionId = Number(match[1]);
-
-    const flagKey = match[2];
-
-    const flag = PERMISSION_FLAGS.find((item) => item.key === flagKey);
-
-    if (!flag) {
-      return;
-    }
-
-    permissionsById.set(
-      permissionId,
-      (permissionsById.get(permissionId) || 0) + flag.bit,
-    );
-  });
-
-  return [...permissionsById.entries()].map(([permission_id, bits]) => ({
-    permission_id,
-    bits,
-  }));
-};
-
-export const buildPermissionTooltipLines = (
-  catalog,
-  assignedPermissions = [],
-  t,
-) => {
-  const catalogById = new Map(catalog.map((permission) => [permission.id, permission]));
-
-  return assignedPermissions.reduce((lines, permission) => {
-    const catalogPermission = catalogById.get(permission.permission_id);
-
-    if (!catalogPermission) {
-      return lines;
-    }
-
-    const labels = PERMISSION_FLAGS.filter(
-      (flag) =>
-        (catalogPermission.bits & flag.bit) === flag.bit &&
-        (permission.bits & flag.bit) === flag.bit,
-    ).map((flag) => t(flag.labelKey));
-
-    if (!labels.length) {
-      return lines;
-    }
-
-    lines.push(`${catalogPermission.description}: ${labels.join(", ")}`);
-
-    return lines;
-  }, []);
-};

+ 15 - 5
src/router/index.js

@@ -1,14 +1,18 @@
-import { route } from "quasar/wrappers";
+import { Cookies, Notify } from "quasar";
+
 import {
   createRouter,
   createMemoryHistory,
   createWebHistory,
   createWebHashHistory,
 } from "vue-router";
-import routes from "./routes";
-import { Cookies, Notify } from "quasar";
+
 import { permissionStore } from "src/stores/permission";
+import { route } from "quasar/wrappers";
 import { useI18n } from "vue-i18n";
+
+import routes from "./routes";
+
 /*
  * If not building with SSR mode, you can
  * directly export the Router instantiation;
@@ -37,25 +41,31 @@ export default route(function (/* { store, ssrContext } */) {
 
   Router.beforeEach(async (to, from, next) => {
     const { getAccess } = permissionStore();
+
     const access_token = Cookies.get("access_token");
+
     if (to.meta.requireAuth && !access_token) {
       return next({ name: "LoginPage" });
     }
+
     if (access_token) {
       if (to.name == "LoginPage") {
         return next({ name: "DashboardPage" });
       }
     }
+
     if (to.meta.requiredPermission) {
       const permission = getAccess(to.meta.requiredPermission, "view");
+
       if (!permission) {
         Notify.create({
-          message: useI18n().t("validation.permissions.view"),
-          type: "negative",
+          message: useI18n().t("validation.permissions.view"), type: "negative",
         });
+
         return next(from);
       }
     }
+
     return next();
   });
 

+ 17 - 5
src/router/routes.js

@@ -3,30 +3,36 @@ import versionRoutes from "./routes/version.route";
 const routes = [
   {
     path: "/",
+
     component: () => import("layouts/MainLayout.vue"),
+
     meta: { requireAuth: true },
+
     children: [
       {
-        path: "",
-        redirect: { name: "DashboardPage" },
+        path: "", redirect: { name: "DashboardPage" },
       },
     ],
   },
 
   {
     path: "/dashboard",
+
     component: () => import("layouts/MainLayout.vue"),
+
     children: [
       {
         path: "",
         name: "DashboardPage",
+
         component: () => import("pages/dashboard/DashboardPage.vue"),
+
         meta: {
-          title: "Dashboard do Proprietário",
-          requireAuth: true,
+          title: "Dashboard do Proprietário", requireAuth: true,
+
           breadcrumbs: [
             {
-              name: "DashboardPage",
+              name:  "DashboardPage",
               title: "Dashboard do Proprietário",
             },
           ],
@@ -34,14 +40,19 @@ const routes = [
       },
     ],
   },
+
   {
     path: "/login",
+
     component: () => import("layouts/LoginLayout.vue"),
+
     children: [
       {
         path: "",
         name: "LoginPage",
+
         component: () => import("pages/LoginPage.vue"),
+
         meta: {
           title: "Login do Proprietário",
         },
@@ -55,6 +66,7 @@ const routes = [
   // but you can also remove it
   {
     path: "/:catchAll(.*)*",
+
     component: () => import("pages/ErrorNotFound.vue"),
   },
 ];

+ 0 - 33
src/router/routes/guest.route.js

@@ -1,33 +0,0 @@
-export default [
-  {
-    path: "guests",
-    name: "GuestsPage",
-    component: () => import("pages/guests/GuestsPage.vue"),
-    meta: {
-      title: "Hóspedes",
-      requireAuth: true,
-      requiredPermission: "config",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "GuestsPage",
-          title: "Hóspedes",
-        },
-      ],
-    },
-  },
-
-  {
-    path: "guest/:id",
-    name: "GuestEditPage",
-    component: () => import("pages/guests/GuestActionPage.vue"),
-    meta: {
-      title: "Dados do Usuário",
-      requireAuth: true,
-      requiredPermission: "config",
-    },
-  },
-];

+ 0 - 92
src/router/routes/property.route.js

@@ -1,92 +0,0 @@
-export default [
-  {
-    path: "business",
-    name: "BusinessPage",
-    component: () => import("pages/business/BusinessPage.vue"),
-    meta: {
-      title: "Empreendimento",
-      requireAuth: true,
-      requiredPermission: "config.city",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          title: "Propriedades",
-        },
-        {
-          name: "BusinessPage",
-          title: "Empreendimento",
-        },
-      ],
-    },
-  },
-
-  {
-    path: "business/create",
-    name: "BusinessCreatePage",
-    component: () => import("pages/business/BusinessActionPage.vue"),
-    meta: {
-      title: "Criar Empreendimento",
-      requireAuth: true,
-      requiredPermission: "config.city",
-    },
-  },
-  {
-    path: "business/:id",
-    name: "BusinessEditPage",
-    component: () => import("pages/business/BusinessActionPage.vue"),
-    meta: {
-      title: "Editar Empreendimento",
-      requireAuth: true,
-      requiredPermission: "config.city",
-    },
-  },
-
-  {
-    path: "properties",
-    name: "PropertyPage",
-    component: () => import("pages/property/PropertyPage.vue"),
-    meta: {
-      title: "Apartamentos",
-      requireAuth: true,
-      requiredPermission: "config.city",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          title: "Propriedades",
-        },
-        {
-          name: "PropertyPage",
-          title: "Imóveis",
-        },
-      ],
-    },
-  },
-
-  {
-    path: "advertisement/create",
-    name: "AdvertisementCreatePage",
-    component: () => import("pages/advertisement/AdvertisementActionPage.vue"),
-    meta: {
-      title: "Criar Anúncio",
-      requireAuth: true,
-      requiredPermission: "config.city",
-    },
-  },
-
-  {
-    path: "advertisement/:id",
-    name: "AdvertisementEditPage",
-    component: () => import("pages/advertisement/AdvertisementActionPage.vue"),
-    meta: {
-      title: "Editar Anúncio",
-      requireAuth: true,
-      requiredPermission: "config.city",
-    },
-  },
-];

+ 0 - 86
src/router/routes/recepcionist.route.js

@@ -1,86 +0,0 @@
-export default [
-  {
-    path: "recepcionist",
-    name: "RecepcionistPage",
-    component: () => import("pages/recepcionist/RecepcionistPage.vue"),
-    meta: {
-      title: "Recepcionista",
-      requireAuth: true,
-      requiredPermission: "config",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "RecepcionistPage",
-          title: "Recepcionista",
-        },
-      ],
-    },
-  },
-
-  {
-    path: "recepcionist/add",
-    name: "RecepcionistAddPage",
-    component: () => import("pages/recepcionist/RecepcionistActionPage.vue"),
-    meta: {
-      title: "Criar Recepcionista",
-      requireAuth: true,
-      requiredPermission: "config.recepcionist",
-    },
-  },
-
-  {
-    path: "recepcionist/:id",
-    name: "RecepcionistEditPage",
-    component: () => import("pages/recepcionist/RecepcionistActionPage.vue"),
-    meta: {
-      title: "Editar Recepcionista",
-      requireAuth: true,
-      requiredPermission: "config.recepcionist",
-    },
-  },
-
-  {
-    path: "checkin",
-    name: "CheckInPage",
-    component: () => import("pages/recepcionist/CheckInPage.vue"),
-    meta: {
-      title: "Check-in",
-      requireAuth: true,
-      requiredPermission: "recepcionist",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "CheckInPage",
-          title: "Check-in",
-        },
-      ],
-    },
-  },
-
-  {
-    path: "checkout",
-    name: "CheckOutPage",
-    component: () => import("pages/recepcionist/CheckOutPage.vue"),
-    meta: {
-      title: "Check-out",
-      requireAuth: true,
-      requiredPermission: "recepcionist",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "CheckOutPage",
-          title: "Check-out",
-        },
-      ],
-    },
-  },
-];

+ 0 - 134
src/router/routes/service.route.js

@@ -1,134 +0,0 @@
-export default [
-  {
-    path: "services",
-    name: "ServicesPage",
-    component: () => import("pages/services/ServicesPage.vue"),
-    meta: {
-      title: "Serviços",
-      requireAuth: true,
-      requiredPermission: "config.city",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "ServicesPage",
-          title: "Serviços",
-        },
-      ],
-    },
-  },
-  {
-    path: "services/create",
-    name: "ServicesCreatePage",
-    component: () => import("pages/services/ServicesActionsPage.vue"),
-    meta: {
-      title: "Criar Serviço",
-      requireAuth: true,
-      requiredPermission: "config",
-    },
-  },
-  {
-    path: "services/:id",
-    name: "ServicesEditPage",
-    component: () => import("pages/services/ServicesActionsPage.vue"),
-    meta: {
-      title: "Editar Serviço",
-      requireAuth: true,
-      requiredPermission: "config",
-    },
-  },
-  {
-    path: "providers",
-    name: "ProvidersPage",
-    component: () => import("pages/providers/ProvidersPage.vue"),
-    meta: {
-      title: "Prestadores",
-      requireAuth: true,
-      requiredPermission: "config",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "ServicesPage",
-          title: "Serviços",
-        },
-        {
-          name: "ProvidersPage",
-          title: "Prestadores",
-        },
-      ],
-    },
-  },
-  {
-    path: "providers/create",
-    name: "ProvidersCreatePage",
-    component: () => import("pages/providers/ProvidersActionsPage.vue"),
-    meta: {
-      title: "Criar Prestador",
-      requireAuth: true,
-      requiredPermission: "config",
-    },
-  },
-  {
-    path: "providers/:id",
-    name: "ProvidersEditPage",
-    component: () => import("pages/providers/ProvidersActionsPage.vue"),
-    meta: {
-      title: "Editar Prestador",
-      requireAuth: true,
-      requiredPermission: "config",
-    },
-  },
-  {
-    path: "payments",
-    name: "PaymentsPage",
-    component: () => import("pages/payments/PaymentsPage.vue"),
-    meta: {
-      title: "Serviços Solicitados e Pgto",
-      requireAuth: true,
-      requiredPermission: "config",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "ServicesPage",
-          title: "Serviços",
-        },
-        {
-          name: "PaymentsPage",
-          title: "Serviços Solicitados e Pgto",
-        },
-      ],
-    },
-  },
-  {
-    path: "payments/:id",
-    name: "PaymentEditPage",
-    component: () => import("pages/payments/PaymentActionsPage.vue"),
-    meta: {
-      title: "Ajustar Serviço",
-      requireAuth: true,
-      requiredPermission: "config",
-      breadcrumbs: [
-        {
-          name: "WelcomePage",
-          title: "Inicio",
-        },
-        {
-          name: "PaymentPage",
-          title: "Serviços Solicitados e Pgto",
-        },
-        {
-          name: "PaymentEditPage",
-          title: "Ajustar Serviço",
-        },
-      ],
-    },
-  },
-];

+ 0 - 90
src/router/routes/users.route.js

@@ -1,90 +0,0 @@
-const settingsBreadcrumbs = [
-  {
-    name: "WelcomePage",
-    title: "Inicio",
-  },
-  {
-    name: "SettingsPage",
-    title: "Acessos",
-  },
-];
-
-export default [
-  {
-    path: "/settings",
-    name: "SettingsPage",
-    component: () => import("pages/settings/SettingsPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-  {
-    path: "/settings/users",
-    name: "SettingsUsersPage",
-    component: () => import("pages/settings/SettingsUsersPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config.user",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-  {
-    path: "/settings/users/new",
-    name: "SettingsUserCreatePage",
-    component: () => import("pages/settings/SettingsUserActionPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config.user",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-  {
-    path: "/settings/users/:id",
-    name: "SettingsUserEditPage",
-    component: () => import("pages/settings/SettingsUserActionPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config.user",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-  {
-    path: "/settings/user-types",
-    name: "SettingsUserTypesPage",
-    component: () => import("pages/settings/SettingsUserTypesPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config.permission",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-  {
-    path: "/settings/user-types/new",
-    name: "SettingsUserTypeCreatePage",
-    component: () => import("pages/settings/SettingsUserTypeActionPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config.permission",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-  {
-    path: "/settings/user-types/:userTypeId",
-    name: "SettingsUserTypeEditPage",
-    component: () => import("pages/settings/SettingsUserTypeActionPage.vue"),
-    meta: {
-      title: "Acessos",
-      requireAuth: true,
-      requiredPermission: "config.permission",
-      breadcrumbs: settingsBreadcrumbs,
-    },
-  },
-];

+ 8 - 4
src/router/routes/version.route.js

@@ -1,22 +1,26 @@
 export default [
   {
     path: "/version",
+
     component: () => import("layouts/MainLayout.vue"),
+
     children: [
       {
         path: "",
         name: "VersionPage",
+
         component: () => import("src/pages/VersionPage.vue"),
+
         meta: {
-          title: "Versões do Sistema",
-          requireAuth: true,
+          title: "Versões do Sistema", requireAuth: true,
+
           breadcrumbs: [
             {
-              name: "DashboardPage",
+              name:  "DashboardPage",
               title: "Início",
             },
             {
-              name: "VersionPage",
+              name:  "VersionPage",
               title: "Versões do Sistema",
             },
           ],

+ 6 - 6
src/stores/navigation.js

@@ -1,14 +1,14 @@
-import { defineStore } from "pinia";
 import { computed } from "vue";
+import { defineStore } from "pinia";
 
 export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
     {
-      type: "single",
-      title: "Dashboard",
-      name: "DashboardPage",
-      icon: "mdi-monitor-dashboard",
-      disable: false,
+      type:       "single",
+      title:      "Dashboard",
+      name:       "DashboardPage",
+      icon:       "mdi-monitor-dashboard",
+      disable:    false,
       permission: true,
     },
   ]);

+ 68 - 57
src/stores/permission.js

@@ -1,74 +1,70 @@
+import { computed, ref } from "vue";
+import { Cookies } from "quasar";
 import { defineStore } from "pinia";
-import { ref, computed } from "vue";
-import { userStore } from "src/stores/user";
 import { getUserPermissions, getGuestPermissions } from "src/api/permission";
-import { Cookies } from "quasar";
+import { userStore } from "src/stores/user";
 
 export const permissionStore = defineStore("permission", () => {
   const bitwisePermissionTable = Object.freeze({
-    view: 1,
-    add: 2,
-    edit: 4,
+    view:   1,
+    add:    2,
+    edit:   4,
     delete: 8,
-    print: 16,
+    print:  16,
     export: 32,
     import: 64,
-    limit: 128,
-    menu: 256,
+    limit:  128,
+    menu:   256,
   });
 
   const bitwisePermissions = ref({
-    view: 0,
-    add: 0,
-    edit: 0,
+    view:   0,
+    add:    0,
+    edit:   0,
     delete: 0,
-    print: 0,
+    print:  0,
     export: 0,
     import: 0,
-    limit: 0,
-    menu: 0,
+    limit:  0,
+    menu:   0,
   });
 
   const originalBitwisePermissions = ref(null);
+  
   const permissions = ref(null);
 
   const totalBitwisePermissions = computed(() =>
     Object.values(bitwisePermissionTable).reduce((a, b) => a + b),
   );
 
+  const checkPermission = (permission, total) => {
+    return !!(permission & total);
+  };
+
   const checkTotalPermissions = (permission) => {
     return !!(permission & totalBitwisePermissions.value);
   };
 
-  const checkPermission = (permission, total) => {
-    return !!(permission & total);
-  };
+  //
 
-  const updateBitMasks = (total) => {
-    const totalPermissions = totalBitwisePermissions.value;
+  const fetchScopes = async () => {
+    try {
+      const accessToken = Cookies.get("access_token");
 
-    const setPermission = (permissionKey, permissionValue) => {
-      bitwisePermissions.value[permissionKey] =
-        total & permissionValue ? permissionValue : 0;
-    };
+      if (accessToken) {
+        const [userPermissions] = await Promise.allSettled([
+          getUserPermissions(),
+          userStore().fetchUser(),
+        ]);
 
-    if (total & totalPermissions) {
-      setPermission("view", bitwisePermissionTable.view);
-      setPermission("add", bitwisePermissionTable.add);
-      setPermission("edit", bitwisePermissionTable.edit);
-      setPermission("delete", bitwisePermissionTable.delete);
-      setPermission("print", bitwisePermissionTable.print);
-      setPermission("export", bitwisePermissionTable.export);
-      setPermission("import", bitwisePermissionTable.import);
-      setPermission("limit", bitwisePermissionTable.limit);
-      setPermission("menu", bitwisePermissionTable.menu);
+        permissions.value = userPermissions.value;
+      } else {
+        const response = await getGuestPermissions();
 
-      originalBitwisePermissions.value = { ...bitwisePermissions.value };
-    } else {
-      for (let key in bitwisePermissions.value) {
-        bitwisePermissions.value[key] = 0;
+        permissions.value = response;
       }
-      originalBitwisePermissions.value = { ...bitwisePermissions.value };
+    } catch (error) {
+      console.error("Error fetching permissions:", error);
     }
   };
 
@@ -81,33 +77,48 @@ export const permissionStore = defineStore("permission", () => {
 
     if (permissions.value) {
       let checkPermission = 0;
+
       const scope = permissions.value.find(
         (permission) => permission.scope === scopeName,
       );
 
       if (scope) {
         checkPermission = bitwisePermissionTable[permissionType] || 0;
+
         return scope.bits & checkPermission ? true : false;
       }
     }
+
     return false;
   };
 
-  const fetchScopes = async () => {
-    try {
-      const accessToken = Cookies.get("access_token");
-      if (accessToken) {
-        const [userPermissions] = await Promise.allSettled([
-          getUserPermissions(),
-          userStore().fetchUser(),
-        ]);
-        permissions.value = userPermissions.value;
-      } else {
-        const response = await getGuestPermissions();
-        permissions.value = response;
+  //
+
+  const updateBitMasks = (total) => {
+    const totalPermissions = totalBitwisePermissions.value;
+
+    const setPermission = (permissionKey, permissionValue) => {
+      bitwisePermissions.value[permissionKey] =
+        total & permissionValue ? permissionValue : 0;
+    };
+
+    if (total & totalPermissions) {
+      setPermission("view", bitwisePermissionTable.view);
+      setPermission("add", bitwisePermissionTable.add);
+      setPermission("edit", bitwisePermissionTable.edit);
+      setPermission("delete", bitwisePermissionTable.delete);
+      setPermission("print", bitwisePermissionTable.print);
+      setPermission("export", bitwisePermissionTable.export);
+      setPermission("import", bitwisePermissionTable.import);
+      setPermission("limit", bitwisePermissionTable.limit);
+      setPermission("menu", bitwisePermissionTable.menu);
+
+      originalBitwisePermissions.value = { ...bitwisePermissions.value };
+    } else {
+      for (let key in bitwisePermissions.value) {
+        bitwisePermissions.value[key] = 0;
       }
-    } catch (error) {
-      console.error("Error fetching permissions:", error);
+      originalBitwisePermissions.value = { ...bitwisePermissions.value };
     }
   };
 
@@ -118,13 +129,13 @@ export const permissionStore = defineStore("permission", () => {
   return {
     bitwisePermissions,
     originalBitwisePermissions,
+    permissions,
     totalBitwisePermissions,
-    checkTotalPermissions,
     checkPermission,
-    updateBitMasks,
-    permissions,
-    getAccess,
+    checkTotalPermissions,
     fetchScopes,
+    getAccess,
     resetScopes,
+    updateBitMasks,
   };
 });

+ 11 - 10
src/stores/user.js

@@ -1,35 +1,36 @@
 import { defineStore } from "pinia";
-import { ref } from "vue";
 import { getUser } from "src/api/user";
+import { ref } from "vue";
 
 export const userStore = defineStore("user", () => {
-  const user = ref(null);
+  const user    = ref(null);
   const isAdmin = ref(false);
   const isOwner = ref(false);
 
+  const fetchUser = async () => {
+    const response = await getUser();
+
+    setUser(response);
+  };
+
   const setUser = (userData) => {
-    user.value = userData;
+    user.value    = userData;
     isAdmin.value = userData?.user_type?.description === "admin";
     isOwner.value = userData?.user_type?.description === "owner";
   };
 
   const resetUser = () => {
-    user.value = null;
+    user.value    = null;
     isAdmin.value = false;
     isOwner.value = false;
   };
 
-  const fetchUser = async () => {
-    const response = await getUser();
-    setUser(response);
-  };
-
   return {
     user,
     isAdmin,
     isOwner,
+    fetchUser,
     setUser,
     resetUser,
-    fetchUser,
   };
 });

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است