Browse Source

feat: add tela de edicao para os dados do usuario + add responsividade no dashboard

Gustavo Mantovani 17 hours ago
parent
commit
8145240c53
60 changed files with 1936 additions and 653 deletions
  1. 6 4
      README.md
  2. 34 11
      index.html
  3. 11 33
      jsconfig.json
  4. 2 1
      package.json
  5. 2 1
      src/App.vue
  6. 4 3
      src/api/cacheService.js
  7. 6 6
      src/api/state.js
  8. 5 0
      src/api/user.js
  9. 7 6
      src/boot/axios.js
  10. 11 11
      src/boot/defaultPropsComponents.js
  11. 6 4
      src/boot/socket.io.js
  12. 94 0
      src/components/charts/CardIconChart.vue
  13. 57 0
      src/components/charts/DefaultCard.vue
  14. 91 0
      src/components/charts/DonutChart.vue
  15. 110 0
      src/components/charts/mini/MiniBarChart.vue
  16. 121 0
      src/components/charts/mini/MiniLineChart.vue
  17. 2 7
      src/components/defaults/DefaultAvatarPicker.vue
  18. 1 1
      src/components/defaults/DefaultFilePicker.vue
  19. 8 7
      src/components/defaults/DefaultMultiSelect.vue
  20. 13 2
      src/components/defaults/DefaultPasswordInput.vue
  21. 25 20
      src/components/defaults/DefaultTable.vue
  22. 5 2
      src/components/defaults/DefaultTableServerSide.vue
  23. 22 7
      src/components/payments/ServiceRatingView.vue
  24. 2 2
      src/components/regions/CitySelect.vue
  25. 1 1
      src/components/selects/OwnersSelect.vue
  26. 1 1
      src/composables/useAuth.js
  27. 15 10
      src/composables/useInputRules.js
  28. 12 6
      src/composables/useOwnerDashboard.js
  29. 23 26
      src/composables/useScroll.js
  30. 1 1
      src/composables/useSubmitHandler.js
  31. 4 5
      src/css/app.scss
  32. 1 1
      src/css/editor.scss
  33. 12 12
      src/css/quasar.variables.scss
  34. 8 5
      src/css/table.scss
  35. 38 31
      src/helpers/buildMetricCards.js
  36. 3 3
      src/helpers/convertBase64Image.js
  37. 10 10
      src/helpers/masks.js
  38. 141 30
      src/helpers/utils.js
  39. 6 6
      src/i18n/index.js
  40. 3 3
      src/i18n/locales/es.json
  41. 3 3
      src/layouts/LoginLayout.vue
  42. 9 9
      src/layouts/MainLayout.vue
  43. 8 8
      src/pages/LoginPage.vue
  44. 7 7
      src/pages/VersionPage.vue
  45. 192 127
      src/pages/dashboard/DashboardPage.vue
  46. 108 76
      src/pages/dashboard/components/DashboardAvailabilityPanel.vue
  47. 63 32
      src/pages/dashboard/components/DashboardChannelsPanel.vue
  48. 43 32
      src/pages/dashboard/components/DashboardFiltersBar.vue
  49. 41 18
      src/pages/dashboard/components/DashboardMetricCard.vue
  50. 39 18
      src/pages/dashboard/components/DashboardPayoutTable.vue
  51. 17 11
      src/pages/dashboard/components/DashboardRevenuePanel.vue
  52. 209 0
      src/pages/profile/ProfilePage.vue
  53. 197 0
      src/pages/profile/components/ProfileEditDialog.vue
  54. 2 1
      src/router/index.js
  55. 36 3
      src/router/routes.js
  56. 4 3
      src/router/routes/version.route.js
  57. 5 5
      src/stores/index.js
  58. 13 5
      src/stores/navigation.js
  59. 13 13
      src/stores/permission.js
  60. 3 3
      src/stores/user.js

+ 6 - 4
README.md

@@ -3,6 +3,7 @@
 A skeleton for future projects
 
 ## Install the dependencies
+
 ```bash
 yarn
 # or
@@ -10,32 +11,33 @@ npm install
 ```
 
 ### Start the app in development mode (hot-code reloading, error reporting, etc.)
+
 ```bash
 quasar dev
 ```
 
-
 ### Lint the files
+
 ```bash
 yarn lint
 # or
 npm run lint
 ```
 
-
 ### Format the files
+
 ```bash
 yarn format
 # or
 npm run format
 ```
 
-
-
 ### Build the app for production
+
 ```bash
 quasar build
 ```
 
 ### Customize the configuration
+
 See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

+ 34 - 11
index.html

@@ -1,19 +1,42 @@
-<!DOCTYPE html>
+<!doctype html>
 <html>
   <head>
     <title>Kizzo - Backoffice</title>
 
-    <meta charset="utf-8">
-    <meta name="description" content="<%= productDescription %>">
-    <meta name="format-detection" content="telephone=no">
-    <meta name="msapplication-tap-highlight" content="no">
-    <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
+    <meta charset="utf-8" />
+    <meta name="description" content="<%= productDescription %>" />
+    <meta name="format-detection" content="telephone=no" />
+    <meta name="msapplication-tap-highlight" content="no" />
+    <meta
+      name="viewport"
+      content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
+    />
 
-    <link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png">
-    <link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png">
-    <link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png">
-    <link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
-    <link rel="icon" type="image/icon" href="icons/favicon.ico">
+    <link
+      rel="icon"
+      type="image/png"
+      sizes="128x128"
+      href="icons/favicon-128x128.png"
+    />
+    <link
+      rel="icon"
+      type="image/png"
+      sizes="96x96"
+      href="icons/favicon-96x96.png"
+    />
+    <link
+      rel="icon"
+      type="image/png"
+      sizes="32x32"
+      href="icons/favicon-32x32.png"
+    />
+    <link
+      rel="icon"
+      type="image/png"
+      sizes="16x16"
+      href="icons/favicon-16x16.png"
+    />
+    <link rel="icon" type="image/icon" href="icons/favicon.ico" />
   </head>
   <body>
     <!-- quasar:entry-point -->

+ 11 - 33
jsconfig.json

@@ -2,38 +2,16 @@
   "compilerOptions": {
     "baseUrl": ".",
     "paths": {
-      "src/*": [
-        "src/*"
-      ],
-      "app/*": [
-        "*"
-      ],
-      "components/*": [
-        "src/components/*"
-      ],
-      "layouts/*": [
-        "src/layouts/*"
-      ],
-      "pages/*": [
-        "src/pages/*"
-      ],
-      "assets/*": [
-        "src/assets/*"
-      ],
-      "boot/*": [
-        "src/boot/*"
-      ],
-      "stores/*": [
-        "src/stores/*"
-      ],
-      "vue$": [
-        "node_modules/vue/dist/vue.runtime.esm-bundler.js"
-      ]
+      "src/*": ["src/*"],
+      "app/*": ["*"],
+      "components/*": ["src/components/*"],
+      "layouts/*": ["src/layouts/*"],
+      "pages/*": ["src/pages/*"],
+      "assets/*": ["src/assets/*"],
+      "boot/*": ["src/boot/*"],
+      "stores/*": ["src/stores/*"],
+      "vue$": ["node_modules/vue/dist/vue.runtime.esm-bundler.js"]
     }
   },
-  "exclude": [
-    "dist",
-    ".quasar",
-    "node_modules"
-  ]
-}
+  "exclude": ["dist", ".quasar", "node_modules"]
+}

+ 2 - 1
package.json

@@ -7,7 +7,8 @@
   "type": "module",
   "private": true,
   "scripts": {
-    "lint": "eslint --ext .js,.vue ./",
+    "lint": "eslint --ext .js,.vue ./ && prettier --check \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
+    "lint:fix": "eslint --ext .js,.vue ./ --fix && prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
     "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
     "test": "echo \"No test specified\" && exit 0",
     "dev": "quasar dev",

+ 2 - 1
src/App.vue

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

+ 4 - 3
src/api/cacheService.js

@@ -35,8 +35,8 @@ const getFromCache = async (key) => {
 
   return new Promise((resolve) => {
     const transaction = db.transaction(["apiCache"], "readonly");
-    const store       = transaction.objectStore("apiCache");
-    const request     = store.get(key);
+    const store = transaction.objectStore("apiCache");
+    const request = store.get(key);
 
     request.onsuccess = () => {
       const entry = request.result;
@@ -56,7 +56,8 @@ const setInCache = async (key, data, ttl) => {
   const entry = {
     key,
     data,
-    timestamp: Date.now(), ttl: ttl * 1000,
+    timestamp: Date.now(),
+    ttl: ttl * 1000,
   };
 
   return new Promise((resolve) => {

+ 6 - 6
src/api/state.js

@@ -5,29 +5,29 @@ const api = createCachedApi("state");
 export const getState = async (id) => {
   const { data } = await api.get("/state/" + id);
   return data.payload;
-}
+};
 
 export const getStates = async () => {
   const { data } = await api.get("/state");
   return data.payload;
-}
+};
 
 export const getStateByCountry = async (countryId) => {
   const { data } = await api.get(`/state-country/${countryId}`);
   return data.payload;
-}
+};
 
 export const createState = async (state) => {
   const { data } = await api.post("/state", state);
   return data.payload;
-}
+};
 
 export const updateState = async (state, id) => {
   const { data } = await api.put(`/state/${id}`, state);
   return data.payload;
-}
+};
 
 export const deleteState = async (id) => {
   const { data } = await api.del(`/state/${id}`);
   return data.payload;
-}
+};

+ 5 - 0
src/api/user.js

@@ -30,6 +30,11 @@ export const getUser = async () => {
   return data.payload;
 };
 
+export const changePassword = async (payload) => {
+  const { data } = await api.post("/user/change-password", payload);
+  return data.payload;
+};
+
 export const countUsers = async () => {
   const { data } = await api.get("/user/count");
   return data.payload;

+ 7 - 6
src/boot/axios.js

@@ -5,16 +5,16 @@ import { Cookies, Notify } from "quasar";
 import { useAuth } from "src/composables/useAuth";
 
 const api = axios.create({
-  baseURL:         process.env.API_URL + "/api",
+  baseURL: process.env.API_URL + "/api",
   withCredentials: true,
-  withXSRFToken:   true,
+  withXSRFToken: true,
 });
 
 api.interceptors.request.use(
   async (config) => {
-    const accessToken   = Cookies.get("access_token");
+    const accessToken = Cookies.get("access_token");
     const savedLanguage = Cookies.get("locale");
-    const language      = savedLanguage || window.navigator.language;
+    const language = savedLanguage || window.navigator.language;
 
     config.headers["Accept-Language"] = language;
 
@@ -99,7 +99,8 @@ const errorInterceptor = async (error) => {
 const successInterceptor = (response) => {
   if (response.data.message) {
     Notify.create({
-      message: response.data.message, type: "positive",
+      message: response.data.message,
+      type: "positive",
     });
   }
 
@@ -109,7 +110,7 @@ const successInterceptor = (response) => {
 export default boot(({ app }) => {
   api.interceptors.response.use(
     (response) => successInterceptor(response),
-    (error)    => errorInterceptor(error),
+    (error) => errorInterceptor(error),
   );
 
   // for use inside Vue files (Options API) through this.$axios and this.$api

+ 11 - 11
src/boot/defaultPropsComponents.js

@@ -20,17 +20,17 @@ export default boot(() => {
     transitionHide: "slide-down",
   });
   SetComponentDefaults(QInput, {
-    outlined:   true,
+    outlined: true,
     labelColor: "primary",
-    dense:      true,
-    bgColor:    "surface",
-    class:      "text-bold",
+    dense: true,
+    bgColor: "surface",
+    class: "text-bold",
   });
   SetComponentDefaults(QSelect, {
-    outlined:   true,
+    outlined: true,
     labelColor: "primary",
-    dense:      true,
-    bgColor:    "surface",
+    dense: true,
+    bgColor: "surface",
   });
   SetComponentDefaults(QCard, {
     flat: true,
@@ -38,11 +38,11 @@ export default boot(() => {
   SetComponentDefaults(QScrollArea, {
     thumbStyle: {
       borderRadius: "4px",
-      background:   "#A6A6A6",
-      width:        "6px",
-      opacity:      0.6,
+      background: "#A6A6A6",
+      width: "6px",
+      opacity: 0.6,
     },
-    verticalOffset:   [4, 2],
+    verticalOffset: [4, 2],
     horizontalOffset: [0, 2],
   });
 });

+ 6 - 4
src/boot/socket.io.js

@@ -10,16 +10,17 @@ const socket = io(process.env.WEBSOCKET_API, {
     apiKey: process.env.WEBSOCKET_API_KEY,
   },
 
-  reconnection:      true,
+  reconnection: true,
   reconnectionDelay: 1000,
-  timeout:           20000,
+  timeout: 20000,
 });
 
 const sendEvent = (room, eventName, data) => {
   const channel = process.env.WEBSOCKET_ROOM + ":" + room + "@" + eventName;
 
   socket.emit("eventWrapperToNode", {
-    channel: channel, data: data,
+    channel: channel,
+    data: data,
   });
 };
 
@@ -27,7 +28,8 @@ const sendEventToLaravel = (eventName, data) => {
   const channel = process.env.WEBSOCKET_ROOM + ":" + eventName;
 
   socket.emit("eventWrapperToLaravel", {
-    channel: channel, data: data,
+    channel: channel,
+    data: data,
   });
 };
 

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

@@ -0,0 +1,94 @@
+<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>

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

@@ -0,0 +1,57 @@
+<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>

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

@@ -0,0 +1,91 @@
+<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>

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

@@ -0,0 +1,110 @@
+<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>

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

@@ -0,0 +1,121 @@
+<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>

+ 2 - 7
src/components/defaults/DefaultAvatarPicker.vue

@@ -1,11 +1,6 @@
 <template>
   <div class="column items-center q-gutter-y-sm">
-    <q-avatar
-      size="200px"
-      font-size="80px"
-      color="grey-4"
-      text-color="grey-7"
-    >
+    <q-avatar size="200px" font-size="80px" color="grey-4" text-color="grey-7">
       <q-img
         v-if="previewUrl"
         :src="previewUrl"
@@ -13,7 +8,7 @@
         style="height: 100%; width: 100%"
       />
 
-      <q-icon name="mdi-camera-plus" color="primary"/>
+      <q-icon name="mdi-camera-plus" color="primary" />
     </q-avatar>
 
     <q-btn

+ 1 - 1
src/components/defaults/DefaultFilePicker.vue

@@ -178,7 +178,7 @@ watch(
 
 onMounted(() => {
   console.log(initialImage);
-  console.log(model.value)
+  console.log(model.value);
 });
 </script>
 

+ 8 - 7
src/components/defaults/DefaultMultiSelect.vue

@@ -57,11 +57,11 @@ defineOptions({
 });
 
 const props = defineProps({
-  label:             { type: String, default: "" },
-  rules:             { type: Array,  default: () => [] },
-  inputClass:        { type: String, default: null },
+  label: { type: String, default: "" },
+  rules: { type: Array, default: () => [] },
+  inputClass: { type: String, default: null },
   popupContentClass: { type: String, default: null },
-  bgColor:           { type: String, default: "surface" },
+  bgColor: { type: String, default: "surface" },
 });
 
 const { label, inputClass, popupContentClass, bgColor, rules } = props;
@@ -69,7 +69,8 @@ const { label, inputClass, popupContentClass, bgColor, rules } = props;
 const attrs = useAttrs();
 
 const model = defineModel({
-  type: Array, default: () => [],
+  type: Array,
+  default: () => [],
 });
 
 const error = defineModel("error", {
@@ -77,7 +78,7 @@ const error = defineModel("error", {
 });
 
 const selectRef = ref(null);
-const required  = ref(false);
+const required = ref(false);
 
 const errorMessage = computed(() => {
   if (error.value == null) return void 0;
@@ -109,4 +110,4 @@ defineExpose({
     }
   },
 });
-</script>
+</script>

+ 13 - 2
src/components/defaults/DefaultPasswordInput.vue

@@ -2,7 +2,8 @@
   <q-input
     v-model="password"
     v-bind="$attrs"
-    :label="$t('common.terms.password')"
+    :bg-color="bgColor"
+    :label="label || $t('common.terms.password')"
     :type="!seePassword ? 'password' : 'text'"
     :rules="rules"
   >
@@ -17,7 +18,17 @@
   </q-input>
 </template>
 <script setup>
-const { rules } = defineProps({
+const { bgColor, label, rules } = defineProps({
+  bgColor: {
+    type: String,
+    default: "white",
+  },
+
+  label: {
+    type: String,
+    default: null,
+  },
+
   rules: {
     type: Array,
     default: () => [],

+ 25 - 20
src/components/defaults/DefaultTable.vue

@@ -100,7 +100,12 @@
 import { onMounted, ref, toRaw, watch } from "vue";
 import { useRouter } from "vue-router";
 
-const emit = defineEmits(["onRowClick", "onAddItem", "noRows", "toggleFilters"]);
+const emit = defineEmits([
+  "onRowClick",
+  "onAddItem",
+  "noRows",
+  "toggleFilters",
+]);
 
 const {
   addItem,
@@ -115,30 +120,30 @@ const {
   rowsPerPage,
   showSearchField,
 } = defineProps({
-  addItem:           { type: Boolean,  default: true },
-  addItemRoute:      { type: String,   default: "" },
-  apiCall:           { type: Function, required: true },
-  columns:           { type: Array,    required: true },
-  deleteFunction:    { type: Function, default: null },
-  filterBtn:         { type: Boolean,  default: true },
-  hideNoDataLabel:   { type: Boolean,  default: false },
-  noApiCall:         { type: Boolean,  default: false },
-  outlineAdd:        { type: Boolean,  default: false },
-  openItem:          { type: Boolean,  default: false },
-  openItemRoute:     { type: String,   default: "" },
-  rowsPerPage:       { type: Number,   default: 10 },
-  showColumnsSelect: { type: Boolean,  default: false },
-  showSearchField:   { type: Boolean,  default: true },
+  addItem: { type: Boolean, default: true },
+  addItemRoute: { type: String, default: "" },
+  apiCall: { type: Function, required: true },
+  columns: { type: Array, required: true },
+  deleteFunction: { type: Function, default: null },
+  filterBtn: { type: Boolean, default: true },
+  hideNoDataLabel: { type: Boolean, default: false },
+  noApiCall: { type: Boolean, default: false },
+  outlineAdd: { type: Boolean, default: false },
+  openItem: { type: Boolean, default: false },
+  openItemRoute: { type: String, default: "" },
+  rowsPerPage: { type: Number, default: 10 },
+  showColumnsSelect: { type: Boolean, default: false },
+  showSearchField: { type: Boolean, default: true },
 });
 
 const router = useRouter();
 
-const rows         = ref([]);
-const filter       = ref("");
-const loading      = ref(true);
-const fullscreen   = ref(false);
+const rows = ref([]);
+const filter = ref("");
+const loading = ref(true);
+const fullscreen = ref(false);
 const showInativos = ref(false);
-const inativos     = ref([]);
+const inativos = ref([]);
 
 //
 

+ 5 - 2
src/components/defaults/DefaultTableServerSide.vue

@@ -106,7 +106,9 @@
               <q-item v-bind="selectData.itemProps">
                 <q-item-section>
                   <q-item-label>{{
-                    selectData.opt == 0 ? $t("common.ui.misc.all") : selectData.opt
+                    selectData.opt == 0
+                      ? $t("common.ui.misc.all")
+                      : selectData.opt
                   }}</q-item-label>
                 </q-item-section>
               </q-item>
@@ -114,7 +116,8 @@
           </q-select>
         </div>
         <div class="flex items-center">
-          {{ pagination.from + "-" + pagination.to }} {{ $t("common.ui.table.of") }}
+          {{ pagination.from + "-" + pagination.to }}
+          {{ $t("common.ui.table.of") }}
           {{ pagination.rowsNumber }}
         </div>
         <div class="flex items-center">

+ 22 - 7
src/components/payments/ServiceRatingView.vue

@@ -13,7 +13,11 @@
               padding="1px"
               unelevated
               class="text-subtitle1 state-chip col"
-              :class="rating.general_state === opt.value ? 'state-chip--active' : 'state-chip--inactive'"
+              :class="
+                rating.general_state === opt.value
+                  ? 'state-chip--active'
+                  : 'state-chip--inactive'
+              "
               :label="opt.label"
             />
           </div>
@@ -27,7 +31,11 @@
             disable
             padding="1px"
             class="text-subtitle1 text-weight-regular state-chip col"
-            :class="rating.condition === opt.value ? 'state-chip--active' : 'state-chip--inactive'"
+            :class="
+              rating.condition === opt.value
+                ? 'state-chip--active'
+                : 'state-chip--inactive'
+            "
             :label="opt.label"
           />
         </div>
@@ -38,14 +46,21 @@
             <q-item
               v-for="item in checklistItems"
               :key="item.field"
-              style="padding: 4px 8px; min-height: unset;"
+              style="padding: 4px 8px; min-height: unset"
               dense
             >
               <q-item-section>
-                <q-item-label class="text-body2 text-text-2">{{ item.label }}</q-item-label>
+                <q-item-label class="text-body2 text-text-2">{{
+                  item.label
+                }}</q-item-label>
               </q-item-section>
               <q-item-section side>
-                <q-toggle :model-value="rating[item.field]" color="primary" dense disable />
+                <q-toggle
+                  :model-value="rating[item.field]"
+                  color="primary"
+                  dense
+                  disable
+                />
               </q-item-section>
             </q-item>
           </q-list>
@@ -133,12 +148,12 @@ const conditionOptions = computed(
   border: 1px solid #111212;
 
   &--active {
-    background-color: #467D79;
+    background-color: #467d79;
     color: white !important;
   }
 
   &--inactive {
-    background-color: #F0F3F5;
+    background-color: #f0f3f5;
     color: $primary !important;
   }
 }

+ 2 - 2
src/components/regions/CitySelect.vue

@@ -64,8 +64,8 @@ const { state, label, rules, initialId, disable } = defineProps({
   },
   disable: {
     type: Boolean,
-    default: false
-  }
+    default: false,
+  },
 });
 
 const selectedCity = defineModel();

+ 1 - 1
src/components/selects/OwnersSelect.vue

@@ -13,7 +13,7 @@
 </template>
 
 <script setup>
-import { getOwners } from 'src/api/owner';
+import { getOwners } from "src/api/owner";
 import { onMounted, ref } from "vue";
 
 const { label } = defineProps({

+ 1 - 1
src/composables/useAuth.js

@@ -7,7 +7,7 @@ export const useAuth = () => {
   const login = async (email, password) => {
     try {
       const response = await api.post("/login/owner", {
-        email:    email,
+        email: email,
         password: password,
       });
 

+ 15 - 10
src/composables/useInputRules.js

@@ -7,14 +7,14 @@ export const useInputRules = () => {
   const emailPattern =
     /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
 
-  const cpfPattern      = /^[0-9]{3}\.[0-9]{3}\.[0-9]{3}-[0-9]{2}$/;
-  const cnpjPattern     = /^[0-9]{2}\.[0-9]{3}\.[0-9]{3}\/[0-9]{4}-[0-9]{2}$/;
+  const cpfPattern = /^[0-9]{3}\.[0-9]{3}\.[0-9]{3}-[0-9]{2}$/;
+  const cnpjPattern = /^[0-9]{2}\.[0-9]{3}\.[0-9]{3}\/[0-9]{4}-[0-9]{2}$/;
   const passwordPattern = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/;
-  const cepPattern      = /^[0-9]{5}-[0-9]{3}$/;
+  const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
 
   const inputRules = {
-    required:            (value) => !!value || t("validation.rules.required"),
-    requiredNumber:      (value) => !isNaN(value) || t("validation.rules.required"),
+    required: (value) => !!value || t("validation.rules.required"),
+    requiredNumber: (value) => !isNaN(value) || t("validation.rules.required"),
     requiredHideMessage: (value) => !!value,
 
     min: (min) => (value) =>
@@ -25,9 +25,12 @@ export const useInputRules = () => {
       value?.length <= max ||
       `${t("validation.rules.max")} ${max} ${t("validation.rules.characters")}`,
 
-    minValue: (min) => (value) => value >= min || `${t("validation.rules.min")} ${min}`,
-    maxValue: (max) => (value) => value <= max || `${t("validation.rules.max")} ${max}`,
-    email:    (value) => !value || emailPattern.test(value) || t("validation.rules.email"),
+    minValue: (min) => (value) =>
+      value >= min || `${t("validation.rules.min")} ${min}`,
+    maxValue: (max) => (value) =>
+      value <= max || `${t("validation.rules.max")} ${max}`,
+    email: (value) =>
+      !value || emailPattern.test(value) || t("validation.rules.email"),
 
     emails: (value) => {
       if (!value) return true;
@@ -40,8 +43,10 @@ export const useInputRules = () => {
       );
     },
 
-    cpf:  (value) => !value || cpfPattern.test(value) || t("validation.rules.cpf"),
-    cnpj: (value) => !value || cnpjPattern.test(value) || t("validation.rules.cnpj"),
+    cpf: (value) =>
+      !value || cpfPattern.test(value) || t("validation.rules.cpf"),
+    cnpj: (value) =>
+      !value || cnpjPattern.test(value) || t("validation.rules.cnpj"),
 
     samePassword: (otherValue) => (value) =>
       value === otherValue || t("validation.rules.same_password"),

+ 12 - 6
src/composables/useOwnerDashboard.js

@@ -2,11 +2,11 @@ import { computed, ref } from "vue";
 import { getOwnerDashboard } from "src/api/ownerDashboard";
 
 export function useOwnerDashboard() {
-  const dashboard     = ref(null);
-  const loading       = ref(false);
+  const dashboard = ref(null);
+  const loading = ref(false);
   const revenueSeries = ref([]);
 
-  const summary  = computed(() => dashboard.value?.summary ?? {});
+  const summary = computed(() => dashboard.value?.summary ?? {});
   const channels = computed(() => dashboard.value?.channels ?? []);
 
   const availableReferences = computed(
@@ -48,8 +48,8 @@ export function useOwnerDashboard() {
       const responses = await Promise.all(
         references.map(async (reference) => {
           const historyPayload = await getOwnerDashboard({
-            year:        reference.reference_year,
-            month:       reference.reference_month,
+            year: reference.reference_year,
+            month: reference.reference_month,
             property_id: propertyId,
           });
 
@@ -57,7 +57,13 @@ export function useOwnerDashboard() {
             label: new Intl.DateTimeFormat("pt-BR", {
               month: "short",
             })
-              .format(new Date(reference.reference_year, reference.reference_month - 1, 1))
+              .format(
+                new Date(
+                  reference.reference_year,
+                  reference.reference_month - 1,
+                  1,
+                ),
+              )
               .replace(".", ""),
             value: Number(historyPayload?.summary?.reserve_total ?? 0),
           };

+ 23 - 26
src/composables/useScroll.js

@@ -1,4 +1,4 @@
-import { unref } from 'vue'
+import { unref } from "vue";
 
 export const useScroll = () => {
   /**
@@ -11,54 +11,51 @@ export const useScroll = () => {
    * @returns {boolean} - Whether the scroll was successful
    */
   const scrollToComponent = (targetRef, containerRef, options = {}) => {
-    const {
-      offset   = 50,
-      duration = 150
-    } = options
+    const { offset = 50, duration = 150 } = options;
 
-    const target    = unref(targetRef)
-    const container = unref(containerRef)
+    const target = unref(targetRef);
+    const container = unref(containerRef);
 
-    const targetElement = target?.$el
+    const targetElement = target?.$el;
 
-    const containerElement = container?.$el
+    const containerElement = container?.$el;
 
     if (!targetElement || !containerElement) {
-      console.warn('useScroll: Target or container element not found')
+      console.warn("useScroll: Target or container element not found");
 
-      return false
+      return false;
     }
 
     try {
-      let currentElement = targetElement
+      let currentElement = targetElement;
 
-      let offsetTop = 0
+      let offsetTop = 0;
 
       // Calculate total offset up to the scroll container
       while (currentElement && currentElement !== containerElement) {
-        offsetTop += currentElement.offsetTop
+        offsetTop += currentElement.offsetTop;
 
-        currentElement = currentElement.offsetParent
+        currentElement = currentElement.offsetParent;
       }
 
       if (!currentElement) {
-        console.warn('useScroll: Target is not a child of the container')
+        console.warn("useScroll: Target is not a child of the container");
 
-        return false
+        return false;
       }
 
-      const targetPosition = Math.max(0, offsetTop - offset)
+      const targetPosition = Math.max(0, offsetTop - offset);
 
-      container.setScrollPosition('vertical', targetPosition, duration)
+      container.setScrollPosition("vertical", targetPosition, duration);
 
-      return true
+      return true;
     } catch (error) {
-      console.error('useScroll: Error while scrolling', error)
-      return false
+      console.error("useScroll: Error while scrolling", error);
+      return false;
     }
-  }
+  };
 
   return {
-    scrollToComponent
-  }
-}
+    scrollToComponent,
+  };
+};

+ 1 - 1
src/composables/useSubmitHandler.js

@@ -1,7 +1,7 @@
 import { ref } from "vue";
 
 export function useSubmitHandler(onSuccess) {
-  const loading      = ref(false);
+  const loading = ref(false);
   const serverErrors = ref({});
 
   const execute = async (apiCallThunk) => {

+ 4 - 5
src/css/app.scss

@@ -1,6 +1,6 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
-@import url('https://fonts.googleapis.com/css2?family=Rethink+Sans:ital,wght@0,400..800;1,400..800&display=swap');
+@import url("https://fonts.googleapis.com/css2?family=Rethink+Sans:ital,wght@0,400..800;1,400..800&display=swap");
 
 html {
   font-size: 16px;
@@ -94,7 +94,6 @@ input[type="number"]::-webkit-outer-spin-button {
   border: 1px solid $primary;
 }
 
-
 .q-field--outlined .q-field__control {
   border-radius: 4px;
 }
@@ -113,10 +112,10 @@ input[type="number"]::-webkit-outer-spin-button {
 }
 
 .btn-secondary {
-  background-color: #C7E852;
-  border: 1px solid #08514C;
+  background-color: #c7e852;
+  border: 1px solid #08514c;
   border-radius: 8px;
-  color: #08514C;
+  color: #08514c;
 }
 
 .shadow-1 {

+ 1 - 1
src/css/editor.scss

@@ -89,4 +89,4 @@
   .q-editor-custom :deep(.q-editor__toolbar .q-btn-group) {
     margin: 2px;
   }
-}
+}

+ 12 - 12
src/css/quasar.variables.scss

@@ -1,5 +1,5 @@
-$primary: #08514C;
-$secondary: #C7E852;
+$primary: #08514c;
+$secondary: #c7e852;
 $accent: #e91e63;
 
 $dark: #1d1d1d;
@@ -13,16 +13,16 @@ $info: #0288d1;
 $warning: #ed6c02;
 
 $colors: (
-  "primary": #08514C,
-  "secondary": #C7E852,
+  "primary": #08514c,
+  "secondary": #c7e852,
 
   "text": #111212,
-  "text-2": #4D4E4E,
+  "text-2": #4d4e4e,
   "text-3": #898989,
 
   "primary-light": #42a5f5,
   "primary-dark": #1565c0,
-  "primary-3": #84A8A6,
+  "primary-3": #84a8a6,
 
   "secondary-light": #ba68c8,
   "secondary-dark": #7b1fa2,
@@ -32,21 +32,21 @@ $colors: (
 
   "page": #f1f1f1,
 
-  "surface": #F0F3F5,
+  "surface": #f0f3f5,
   "surface-light": #f5f5f5,
   "surface-dark": #f1f1f1,
 
-  "default-background": #FFF,
+  "default-background": #fff,
 
   "light-2": #555555,
 
-  "success": #59B259,
+  "success": #59b259,
 
   "error": #d32f2f,
   "error-light": #ef5350,
   "error-dark": #c62828,
 
-  "warning": #FFF001,
+  "warning": #fff001,
 
   "info": #0288d1,
   "info-light": #03a9f4,
@@ -54,7 +54,7 @@ $colors: (
 
   "dark-blue": #0d214f,
   "light-brown": #b68678,
-  "dark-brown": #613123
+  "dark-brown": #613123,
 );
 
 $colors-dark: (
@@ -94,7 +94,7 @@ $colors-dark: (
 
   "info": #29b6f6,
   "info-light": #4fc3f7,
-  "info-dark": #0288d1
+  "info-dark": #0288d1,
 );
 
 @each $name, $color in $colors {

+ 8 - 5
src/css/table.scss

@@ -38,7 +38,7 @@
 
   td {
     background-color: white;
-}
+  }
 
   tr {
     background-color: #{map.get($colors, "surface")};
@@ -53,7 +53,7 @@
     padding-top: 16px;
     padding-left: 0px;
     padding-right: 0px;
-    padding-bottom: 16px;;
+    padding-bottom: 16px;
   }
 
   .q-table th {
@@ -201,6 +201,9 @@
   }
 }
 
-.q-table thead, .q-table tr, .q-table th, .q-table td {
-  border: none
-}
+.q-table thead,
+.q-table tr,
+.q-table th,
+.q-table td {
+  border: none;
+}

+ 38 - 31
src/helpers/buildMetricCards.js

@@ -6,69 +6,76 @@ import {
 } from "./utils";
 
 export function buildMetricCards(summary, options = {}) {
-  const {
-    isAllPropertiesSelected = false,
-    referenceLabel          = "",
-  } = options;
+  const { isAllPropertiesSelected = false, referenceLabel = "" } = options;
 
-  const totalExpenses = Number(summary.total_forward_fee_all ?? 0)
-    + Number(summary.total_expenses_amount ?? 0);
+  const totalExpenses = Number(summary.expenses ?? 0);
+  const netBalance = Number(
+    summary.net_revenue ??
+      summary.net_balance_amount ??
+      Number(summary.reserve_total ?? 0) -
+        Number(summary.total_forward_fee_all ?? 0),
+  );
+  const grossRevenue = Number(summary.reserve_total ?? 0);
+  const netRevenue = netBalance;
+  const netPayout = Number(summary.final_payout_amount ?? 0);
 
-  const 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),
+      label: "Faturamento Bruto",
+      value: formatCurrency(grossRevenue),
       caption: referenceLabel || "Mês selecionado",
     },
     {
-      label:   "Faturamento Líquido",
-      value:   formatCurrency(summary.owner_payout_amount),
-      caption: "Após deduções",
+      label: "Faturamento Líquido",
+      value: formatCurrency(netRevenue),
+      caption: "Bruto - taxa OTA",
     },
     {
-      label:   "Diária Média",
-      value:   formatCurrency(summary.average_price_per_night),
+      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: "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: "Repasse Total",
+      value: formatCurrency(netPayout),
+      caption: isAllPropertiesSelected
+        ? "Todos apartamentos"
+        : "No imóvel selecionado",
     },
     {
-      label:   "Ocupação",
-      value:   formatPercent(summary.occupancy_rate, 1),
+      label: "Ocupação",
+      value: formatPercent(summary.occupancy_rate, 1),
       caption: occupancyCaption,
     },
     {
-      label:   "Total de Reservas",
-      value:   formatInteger(summary.reservations_count),
+      label: "Total de Reservas",
+      value: formatInteger(summary.reservations_count),
       caption: "no período",
     },
     {
-      label:   "Dias por Reserva",
-      value:   formatDecimal(summary.average_nights_per_reservation),
+      label: "Dias por Reserva",
+      value: formatDecimal(summary.average_nights_per_reservation),
       caption: "média de permanência",
     },
     {
-      label:   "Limpeza",
-      value:   formatInteger(summary.cleanings_count),
+      label: "Limpeza",
+      value: formatInteger(summary.cleanings_count),
       caption: "total no período",
     },
     {
-      label:   "Despesas",
-      value:   formatCurrency(totalExpenses),
+      label: "Despesas",
+      value: formatCurrency(totalExpenses),
       caption: "manutenção e operação",
     },
   ];

+ 3 - 3
src/helpers/convertBase64Image.js

@@ -3,7 +3,7 @@ const base64ToJPEG = (base64String, fileName) => {
 
   const base64WithoutHeader = base64String.replace(
     /^data:image\/jpeg;base64,/,
-    ""
+    "",
   );
 
   // Converte a string base64 para um array de bytes
@@ -44,7 +44,7 @@ const base64ToPNG = (base64String, fileName) => {
 
   const base64WithoutHeader = base64String.replace(
     /^data:image\/png;base64,/,
-    ""
+    "",
   );
 
   // Converte a string base64 para um array de bytes
@@ -78,6 +78,6 @@ const base64ToPNG = (base64String, fileName) => {
   link.click();
 
   document.body.removeChild(link);
-}
+};
 
 export { base64ToJPEG, base64ToPNG };

+ 10 - 10
src/helpers/masks.js

@@ -7,23 +7,23 @@
 // 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: {
     Brasil: "AAA-#X##",
-  }
+  },
 };
 
 export default masks;

+ 141 - 30
src/helpers/utils.js

@@ -1,28 +1,33 @@
 import { useI18n } from "vue-i18n";
 
+/**
+ * @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",
+      locale: "pt-BR",
+      currency: "BRL",
       currencyDisplay: "symbol",
 
-      hideCurrencySymbolOnFocus:          false,
-      hideGroupingSeparatorOnFocus:       false,
+      hideCurrencySymbolOnFocus: false,
+      hideGroupingSeparatorOnFocus: false,
       hideNegligibleDecimalDigitsOnFocus: false,
-      autoDecimalDigits:                  true,
-      useGrouping:                        true,
-      accountingSign:                     false,
+      autoDecimalDigits: true,
+      useGrouping: true,
+      accountingSign: false,
     };
   } else if (moeda == 2) {
     currencyOptions = {
-      currency:        "PYG",
-      locale:          "es-PY",
-      valueAsInteger:  true,
+      currency: "PYG",
+      locale: "es-PY",
+      valueAsInteger: true,
       distractionFree: true,
-      precision:       0,
+      precision: 0,
       autoDecimalMode: true,
 
       valueRange: { min: 0 },
@@ -31,22 +36,28 @@ const checaMoeda = (moeda) => {
     };
   } else if (moeda == 3) {
     currencyOptions = {
-      locale:          "en-US",
-      currency:        "USD",
+      locale: "en-US",
+      currency: "USD",
       currencyDisplay: "symbol",
 
-      hideCurrencySymbolOnFocus:          true,
-      hideGroupingSeparatorOnFocus:       true,
+      hideCurrencySymbolOnFocus: true,
+      hideGroupingSeparatorOnFocus: true,
       hideNegligibleDecimalDigitsOnFocus: false,
-      autoDecimalDigits:                  true,
-      useGrouping:                        true,
-      accountingSign:                     false,
+      autoDecimalDigits: true,
+      useGrouping: true,
+      accountingSign: false,
     };
   }
 
   return currencyOptions;
 };
 
+/**
+ * @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 excerpt = (string, size = 30) => {
   if (size == null) return string;
 
@@ -61,17 +72,27 @@ const excerpt = (string, size = 30) => {
 
 // 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
@@ -81,6 +102,13 @@ const convertDateTime = (dateTimeString) => {
   return formattedDateTime;
 };
 
+/**
+ * @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"));
 
@@ -95,6 +123,14 @@ const formatDateDMYtoYMD = (date, time) => {
   return `${year}-${month}-${day} ${time ? time : ""}`;
 };
 
+/**
+ * @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 formatDateYMDtoDMY = (dateTime) => {
   const [datePart, timePart] = dateTime.split(" ");
 
@@ -115,6 +151,11 @@ const formatDateYMDtoDMY = (dateTime) => {
 
 // formatters de moeda e number
 
+/**
+ * @description Converte um valor monetário para centavos.
+ * @param {number|string|null|undefined} value valor a ser convertido.
+ * @returns {number|null} valor convertido em centavos.
+ */
 const convertToCents = (value) => {
   if (value === null || value === undefined || value === "") return null;
 
@@ -133,23 +174,38 @@ const convertToCents = (value) => {
   return null;
 };
 
+/**
+ * @description Formata a moeda.
+ * @param {number} value valor.
+ * @returns {string} valor formatado.
+ */
 const formatCurrency = (value) => {
   return new Intl.NumberFormat("pt-BR", {
-    style:    "currency",
+    style: "currency",
     currency: "BRL",
     minimumFractionDigits: 2,
   }).format(Number(value ?? 0));
 };
 
+/**
+ * @description Formata a moeda de forma compacta.
+ * @param {number} value valor.
+ * @returns {string} valor formatado.
+ */
 const formatCurrencyCompact = (value) => {
   return new Intl.NumberFormat("pt-BR", {
-    style:    "currency",
+    style: "currency",
     currency: "BRL",
     notation: "compact",
     maximumFractionDigits: 1,
   }).format(Number(value ?? 0));
 };
 
+/**
+ * @description Formata um valor decimal.
+ * @param {number} value valor.
+ * @returns {string} valor formatado.
+ */
 const formatDecimal = (value) => {
   return Number(value ?? 0).toLocaleString("pt-BR", {
     minimumFractionDigits: 1,
@@ -157,22 +213,40 @@ const formatDecimal = (value) => {
   });
 };
 
+/**
+ * @description Formata um valor inteiro.
+ * @param {number} value valor.
+ * @returns {string} valor formatado.
+ */
 const formatInteger = (value) => {
   return Number(value ?? 0).toLocaleString("pt-BR");
 };
 
+/**
+ * @description Formata um valor percentual.
+ * @param {number} value valor.
+ * @param {number} digits quantidade de casas decimais.
+ * @returns {string} valor formatado.
+ */
 const formatPercent = (value, digits = 2) => {
-  return `${Number(value ?? 0).toFixed(digits).replace(".", ",")}%`;
+  return `${Number(value ?? 0)
+    .toFixed(digits)
+    .replace(".", ",")}%`;
 };
 
 // filters
 
+/**
+ * @description Filtra a moeda.
+ * @param {number} value valor.
+ * @returns {string} valor formatado.
+ */
 const filterCurrency = (value) => {
   if (value) {
     value = parseFloat(value);
 
     return value.toLocaleString("pt-BR", {
-      style:    "currency",
+      style: "currency",
       currency: "BRL",
     });
   }
@@ -180,24 +254,44 @@ const filterCurrency = (value) => {
   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, ".");
 };
 
 // validators
 
+/**
+ * @description Valida se a data é válida.
+ * @param {string} date data.
+ * @returns {boolean} true se a data é válida, false caso contrário.
+ */
 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]$/;
@@ -207,15 +301,32 @@ const validaDataHora = (dataHora) => {
 
 // outros
 
+/**
+ * @description Trunca o nome de um arquivo preservando a extensão.
+ * @param {string} name nome do arquivo.
+ * @returns {string} nome truncado.
+ */
 const truncatedName = (name) => {
   if (name.length <= 15) return name;
 
-  const extension      = name.split(".").pop();
+  const extension = name.split(".").pop();
   const nameWithoutExt = name.substring(0, name.lastIndexOf("."));
 
   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 "";
@@ -232,7 +343,7 @@ export const maskCpf = (cpf) => {
   }
 
   const firstPart = cleanedCpf.slice(0, 3);
-  const lastPart  = cleanedCpf.slice(9, 11);
+  const lastPart = cleanedCpf.slice(9, 11);
 
   return `${firstPart}.***.***-${lastPart}`;
 };

+ 6 - 6
src/i18n/index.js

@@ -3,30 +3,30 @@ import pt from "./locales/pt.json";
 import es from "./locales/es.json";
 
 export default {
-  "en": en,
+  en: en,
   "en-us": en,
   "en-gb": en,
   "en-US": en,
   "en-GB": en,
-  "EN": en,
+  EN: en,
   "EN-US": en,
   "EN-GB": en,
-  "pt": pt,
+  pt: pt,
   "pt-br": pt,
   "pt-pt": pt,
   "pt-BR": pt,
   "pt-PT": pt,
-  "PT": pt,
+  PT: pt,
   "PT-BR": pt,
   "PT-PT": pt,
-  "es": es,
+  es: es,
   "es-es": es,
   "es-mx": es,
   "es-ar": es,
   "es-ES": es,
   "es-MX": es,
   "es-AR": es,
-  "ES": es,
+  ES: es,
   "ES-ES": es,
   "ES-MX": es,
   "ES-AR": es,

+ 3 - 3
src/i18n/locales/es.json

@@ -90,9 +90,9 @@
       "misc": {
         "all": "Todos",
         "or": "o",
-      "messages": {
-        "basePriceSaved": "Precio base actualizado"
-      },
+        "messages": {
+          "basePriceSaved": "Precio base actualizado"
+        },
         "example": "Ejemplo",
         "options": "Opciones",
         "total": "Total",

+ 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;
       "
     >

+ 9 - 9
src/layouts/MainLayout.vue

@@ -39,7 +39,9 @@
                     />
                   </q-item-section>
 
-                  <q-item-section>{{ $t("user.profile.singular") }}</q-item-section>
+                  <q-item-section>{{
+                    $t("ui.navigation.profile")
+                  }}</q-item-section>
                 </div>
               </q-item>
 
@@ -53,7 +55,7 @@
                     />
                   </q-item-section>
 
-                  <q-item-section>{{ $t('auth.logout') }}</q-item-section>
+                  <q-item-section>{{ $t("auth.logout") }}</q-item-section>
                 </div>
               </q-item>
             </q-list>
@@ -76,9 +78,7 @@
             <Transition mode="out-in">
               <component
                 :is="Component"
-                :style="
-                  $q.screen.lt.sm ? 'padding-left: 10px !important;' : ''
-                "
+                :style="$q.screen.lt.sm ? 'padding-left: 10px !important;' : ''"
                 style="padding: 20px !important; padding-right: 10px !important"
               />
             </Transition>
@@ -104,7 +104,7 @@ defineOptions({
 
 const { logout } = useAuth();
 
-const route  = useRoute();
+const route = useRoute();
 const router = useRouter();
 
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
@@ -138,18 +138,18 @@ watch(route, (value) => {
 </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;
 }
 

+ 8 - 8
src/pages/LoginPage.vue

@@ -70,10 +70,10 @@ const router = useRouter();
 
 const { inputRules } = useInputRules();
 
-const email      = ref("");
-const password   = ref(process.env.PASSWORD);
+const email = ref("");
+const password = ref(process.env.PASSWORD);
 const submitting = ref(false);
-const loginForm  = ref(null);
+const loginForm = ref(null);
 
 const submitLogin = async () => {
   try {
@@ -104,15 +104,15 @@ onMounted(() => {
 
 <style lang="scss" scoped>
 .login-page {
-  display:         flex;
+  display: flex;
   justify-content: center;
-  align-items:     center;
+  align-items: center;
 
   .login-card {
-    width:         100%;
-    max-width:     600px;
+    width: 100%;
+    max-width: 600px;
     border-radius: 12px;
-    padding-top:   40px;
+    padding-top: 40px;
   }
 }
 </style>

+ 7 - 7
src/pages/VersionPage.vue

@@ -39,11 +39,11 @@
                 <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;
                   "
                 >
@@ -76,21 +76,21 @@ 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",

+ 192 - 127
src/pages/dashboard/DashboardPage.vue

@@ -65,7 +65,7 @@
             <DashboardPayoutTable
               :rows="payoutRows"
               :total-reservations="Number(summary.reservations_count ?? 0)"
-              :total-value="Number(summary.owner_payout_amount ?? 0)"
+              :total-value="Number(summary.final_payout_amount ?? 0)"
             />
           </q-card>
 
@@ -140,34 +140,34 @@ ChartJS.register(
 
 const $q = useQuasar();
 
-const dashboard              = ref(null);
-const loading                = ref(false);
-const exporting              = ref(false);
+const dashboard = ref(null);
+const loading = ref(false);
+const exporting = ref(false);
 const selectedPropertyOption = ref(null);
-const selectedMonth          = ref(null);
-const selectedYear           = ref(null);
-const revenueSeries          = ref([]);
-const dashboardRequestId     = ref(0);
-const historyRequestId       = ref(0);
+const selectedMonth = ref(null);
+const selectedYear = ref(null);
+const revenueSeries = ref([]);
+const dashboardRequestId = ref(0);
+const historyRequestId = ref(0);
 
 const defaultSummary = Object.freeze({
-  reserve_total:                  0,
-  total_forward_fee_all:          0,
-  owner_payout_amount:            0,
-  final_payout_amount:            0,
-  total_expenses_amount:          0,
-  occupied_nights_in_month:       0,
-  occupancy_rate:                 0,
-  average_price_per_night:        null,
-  average_reservation_ticket:     null,
+  reserve_total: 0,
+  total_forward_fee_all: 0,
+  owner_payout_amount: 0,
+  final_payout_amount: 0,
+  total_expenses_amount: 0,
+  occupied_nights_in_month: 0,
+  occupancy_rate: 0,
+  average_price_per_night: null,
+  average_reservation_ticket: null,
   average_nights_per_reservation: 0,
-  maintenance_days:               0,
-  cleanings_count:                0,
-  expenses:                       0,
-  reservations_count:             0,
-  available_days:                 0,
-  days_in_month:                  30,
-  properties_count:               1,
+  maintenance_days: 0,
+  cleanings_count: 0,
+  expenses: 0,
+  reservations_count: 0,
+  available_days: 0,
+  days_in_month: 30,
+  properties_count: 1,
 });
 
 const monthLabels = [
@@ -203,7 +203,7 @@ const propertyOptions = computed(() => {
   return [
     { id: null, label: "Todos os imóveis" },
     ...properties.map((property) => ({
-      id:    property.id,
+      id: property.id,
       label: property.label,
     })),
   ];
@@ -218,27 +218,38 @@ const selectedPropertyId = computed(() => {
 });
 
 const canExportReport = computed(
-  () => hasProperties.value && dashboard.value !== null && selectedPropertyId.value !== null,
+  () =>
+    hasProperties.value &&
+    dashboard.value !== null &&
+    selectedPropertyId.value !== null,
 );
 
-const isAllPropertiesSelected = computed(() => selectedPropertyId.value === null);
+const isAllPropertiesSelected = computed(
+  () => selectedPropertyId.value === null,
+);
 
 //
 
-const payoutRows = computed(
-  () => dashboard.value?.properties_breakdown ?? [],
-);
+const payoutRows = computed(() => dashboard.value?.properties_breakdown ?? []);
 
 const summary = computed(() => dashboard.value?.summary ?? defaultSummary);
 
-const totalCapacityDays = computed(
-  () => Math.max(1, Number(summary.value.days_in_month ?? 0) * Number(summary.value.properties_count ?? 1)),
+const totalCapacityDays = computed(() =>
+  Math.max(
+    1,
+    Number(summary.value.days_in_month ?? 0) *
+      Number(summary.value.properties_count ?? 1),
+  ),
 );
 
 //
 
 const yearOptions = computed(() => {
-  const years = [...new Set(availableReferences.value.map((reference) => reference.reference_year))];
+  const years = [
+    ...new Set(
+      availableReferences.value.map((reference) => reference.reference_year),
+    ),
+  ];
 
   return years
     .sort((a, b) => b - a)
@@ -253,11 +264,13 @@ const monthOptions = computed(() => {
     return [];
   }
 
-  const months = [...new Set(
-    availableReferences.value
-      .filter((reference) => reference.reference_year === selectedYear.value)
-      .map((reference) => reference.reference_month),
-  )];
+  const months = [
+    ...new Set(
+      availableReferences.value
+        .filter((reference) => reference.reference_year === selectedYear.value)
+        .map((reference) => reference.reference_month),
+    ),
+  ];
 
   return months
     .sort((a, b) => a - b)
@@ -274,17 +287,20 @@ const selectedReferenceLabel = computed(() => {
     return "Mês selecionado";
   }
 
-  const monthLabel = monthLabels[selectedMonth.value - 1] ?? String(selectedMonth.value);
+  const monthLabel =
+    monthLabels[selectedMonth.value - 1] ?? String(selectedMonth.value);
 
   return `${monthLabel} ${selectedYear.value}`;
 });
 
-const allMetricCards = computed(() => buildDashboardMetricCards(summary.value, {
-  isAllPropertiesSelected: isAllPropertiesSelected.value,
-  referenceLabel:          selectedReferenceLabel.value,
-  totalCapacityDays:       totalCapacityDays.value,
-}));
-const firstRowCards  = computed(() => allMetricCards.value.slice(0, 5));
+const allMetricCards = computed(() =>
+  buildDashboardMetricCards(summary.value, {
+    isAllPropertiesSelected: isAllPropertiesSelected.value,
+    referenceLabel: selectedReferenceLabel.value,
+    totalCapacityDays: totalCapacityDays.value,
+  }),
+);
+const firstRowCards = computed(() => allMetricCards.value.slice(0, 5));
 const secondRowCards = computed(() => allMetricCards.value.slice(5, 10));
 
 const availabilityItems = computed(() => {
@@ -324,27 +340,27 @@ const revenueChartData = computed(() => {
   const series = revenueSeries.value.length
     ? revenueSeries.value
     : [
-      {
-        label: shortMonthLabel(selectedMonth.value, selectedYear.value),
-        value: Number(summary.value.reserve_total ?? 0),
-      },
-    ];
+        {
+          label: shortMonthLabel(selectedMonth.value, selectedYear.value),
+          value: Number(summary.value.reserve_total ?? 0),
+        },
+      ];
 
   return {
     labels: series.map((item) => item.label),
     datasets: [
       {
-        label:           "Faturamento",
-        data:            series.map((item) => item.value),
-        borderColor:     "#399FE7",
-        borderWidth:     2,
-        fill:            false,
-        tension:         0.35,
+        label: "Faturamento",
+        data: series.map((item) => item.value),
+        borderColor: "#399FE7",
+        borderWidth: 2,
+        fill: false,
+        tension: 0.35,
 
         pointBackgroundColor: "#399FE7",
-        pointBorderColor:     "#399FE7",
+        pointBorderColor: "#399FE7",
 
-        pointRadius:      3,
+        pointRadius: 3,
         pointHoverRadius: 4,
       },
     ],
@@ -352,7 +368,7 @@ const revenueChartData = computed(() => {
 });
 
 const revenueChartOptions = computed(() => ({
-  responsive:          true,
+  responsive: true,
   maintainAspectRatio: false,
 
   plugins: {
@@ -385,7 +401,7 @@ const revenueChartOptions = computed(() => ({
       },
 
       ticks: {
-        color:    "#7B878C",
+        color: "#7B878C",
         callback: (value) => formatCurrencyCompact(value),
       },
     },
@@ -404,7 +420,7 @@ const shortMonthLabel = (month, year) => {
   })
     .format(new Date(year, month - 1, 1))
     .replace(".", "");
-}
+};
 
 //
 
@@ -428,27 +444,28 @@ const buildDashboardParams = ({ year, month, propertyId, format } = {}) => {
   }
 
   return params;
-}
+};
 
 const buildFallbackReportFilename = (format) => {
   const year = String(selectedYear.value ?? "0000");
 
   const month = String(selectedMonth.value ?? "00").padStart(2, "0");
 
-  const propertySegment = selectedPropertyId.value === null
-    ? "all-properties"
-    : `property_${selectedPropertyId.value}`;
+  const propertySegment =
+    selectedPropertyId.value === null
+      ? "all-properties"
+      : `property_${selectedPropertyId.value}`;
 
   return `owner_dashboard_report_${year}${month}_${propertySegment}.${format}`;
-}
+};
 
 //
 
 const downloadBlob = (blob, filename) => {
-  const url  = window.URL.createObjectURL(blob);
+  const url = window.URL.createObjectURL(blob);
   const link = document.createElement("a");
 
-  link.href     = url;
+  link.href = url;
   link.download = filename;
 
   document.body.appendChild(link);
@@ -458,11 +475,15 @@ const downloadBlob = (blob, filename) => {
   document.body.removeChild(link);
 
   window.URL.revokeObjectURL(url);
-}
+};
 
 const findPropertyOptionById = (propertyId) => {
-  return propertyOptions.value.find((option) => option.id === propertyId) ?? propertyOptions.value[0] ?? null;
-}
+  return (
+    propertyOptions.value.find((option) => option.id === propertyId) ??
+    propertyOptions.value[0] ??
+    null
+  );
+};
 
 const resolveReportFilename = (response, format) => {
   const explicitHeader = response.headers["x-report-filename"];
@@ -486,7 +507,7 @@ const resolveReportFilename = (response, format) => {
   }
 
   return buildFallbackReportFilename(format);
-}
+};
 
 //
 
@@ -496,14 +517,14 @@ const extractBlobErrorMessage = async (blob) => {
   }
 
   try {
-    const text   = await blob.text();
+    const text = await blob.text();
     const parsed = JSON.parse(text);
 
     return parsed?.message ?? null;
   } catch {
     return null;
   }
-}
+};
 
 const exportDashboardReport = async (format) => {
   if (!canExportReport.value) {
@@ -513,24 +534,28 @@ const exportDashboardReport = async (format) => {
   exporting.value = true;
 
   try {
-    const response = await downloadOwnerDashboardReport(buildDashboardParams({
-      year:       selectedYear.value,
-      month:      selectedMonth.value,
-      propertyId: selectedPropertyId.value,
-      format,
-    }));
+    const response = await downloadOwnerDashboardReport(
+      buildDashboardParams({
+        year: selectedYear.value,
+        month: selectedMonth.value,
+        propertyId: selectedPropertyId.value,
+        format,
+      }),
+    );
 
     downloadBlob(response.data, resolveReportFilename(response, format));
   } catch (error) {
     const blobMessage = await extractBlobErrorMessage(error?.response?.data);
 
     $q.notify({
-      type: "negative", message: blobMessage ?? "Não foi possível exportar o relatório do dashboard.",
+      type: "negative",
+      message:
+        blobMessage ?? "Não foi possível exportar o relatório do dashboard.",
     });
   } finally {
     exporting.value = false;
   }
-}
+};
 
 //
 
@@ -553,14 +578,17 @@ const fetchRevenueHistory = async (payload) => {
       references.map(async (reference) => {
         const historyPayload = await getOwnerDashboard(
           buildDashboardParams({
-            year:  reference.reference_year,
+            year: reference.reference_year,
             month: reference.reference_month,
             propertyId,
           }),
         );
 
         return {
-          label: shortMonthLabel(reference.reference_month, reference.reference_year),
+          label: shortMonthLabel(
+            reference.reference_month,
+            reference.reference_year,
+          ),
           value: Number(historyPayload?.summary?.reserve_total ?? 0),
         };
       }),
@@ -574,9 +602,9 @@ const fetchRevenueHistory = async (payload) => {
       revenueSeries.value = [];
     }
   }
-}
+};
 
-const fetchDashboard = async ({ year, month, propertyId } = {}) =>   {
+const fetchDashboard = async ({ year, month, propertyId } = {}) => {
   const requestId = ++dashboardRequestId.value;
 
   loading.value = true;
@@ -592,9 +620,11 @@ const fetchDashboard = async ({ year, month, propertyId } = {}) =>   {
 
     dashboard.value = payload;
 
-    selectedPropertyOption.value = findPropertyOptionById(payload.filters.selected?.property_id ?? null);
-    selectedMonth.value          = payload.filters.selected?.reference_month ?? null;
-    selectedYear.value           = payload.filters.selected?.reference_year ?? null;
+    selectedPropertyOption.value = findPropertyOptionById(
+      payload.filters.selected?.property_id ?? null,
+    );
+    selectedMonth.value = payload.filters.selected?.reference_month ?? null;
+    selectedYear.value = payload.filters.selected?.reference_year ?? null;
 
     await fetchRevenueHistory(payload);
   } catch (error) {
@@ -603,14 +633,17 @@ const fetchDashboard = async ({ year, month, propertyId } = {}) =>   {
     }
 
     $q.notify({
-      type: "negative", message: error?.response?.data?.message ?? "Não foi possível carregar o dashboard do proprietário.",
+      type: "negative",
+      message:
+        error?.response?.data?.message ??
+        "Não foi possível carregar o dashboard do proprietário.",
     });
   } finally {
     if (requestId === dashboardRequestId.value) {
       loading.value = false;
     }
   }
-}
+};
 
 //
 
@@ -624,11 +657,11 @@ const handlePropertyChange = async (propertyOption) => {
   }
 
   await fetchDashboard({
-    year:       selectedYear.value,
-    month:      selectedMonth.value,
+    year: selectedYear.value,
+    month: selectedMonth.value,
     propertyId: nextPropertyId,
   });
-}
+};
 
 //
 
@@ -638,26 +671,32 @@ const handleMonthChange = async (month) => {
   }
 
   await fetchDashboard({
-    year: selectedYear.value, month, propertyId: selectedPropertyId.value,
+    year: selectedYear.value,
+    month,
+    propertyId: selectedPropertyId.value,
   });
-}
+};
 
 const handleYearChange = async (year) => {
-  const availableMonth = availableReferences.value
-    .filter((reference) => reference.reference_year === year)
-    .sort((a, b) => b.reference_month - a.reference_month)[0]?.reference_month ?? selectedMonth.value;
+  const availableMonth =
+    availableReferences.value
+      .filter((reference) => reference.reference_year === year)
+      .sort((a, b) => b.reference_month - a.reference_month)[0]
+      ?.reference_month ?? selectedMonth.value;
 
   if (
-    year === selectedFilters.value.reference_year
-    && availableMonth === selectedFilters.value.reference_month
+    year === selectedFilters.value.reference_year &&
+    availableMonth === selectedFilters.value.reference_month
   ) {
     return;
   }
 
   await fetchDashboard({
-    year, month: availableMonth, propertyId: selectedPropertyId.value,
+    year,
+    month: availableMonth,
+    propertyId: selectedPropertyId.value,
   });
-}
+};
 
 //
 
@@ -672,10 +711,11 @@ onMounted(async () => {
 }
 
 .dashboard-shell {
-  position:       relative;
-  display:        flex;
+  position: relative;
+  display: flex;
   flex-direction: column;
-  gap:            16px;
+  gap: 16px;
+  min-width: 0;
 }
 
 .dashboard-separator {
@@ -684,38 +724,37 @@ onMounted(async () => {
 }
 
 .dashboard-section-caption {
-  margin-top:      -2px;
-  margin-bottom:   -4px;
-  color:           #08514c;
-  font-size:       19px;
-  font-weight:     400;
-  line-height:     1.1;
-  vertical-align:  middle;
+  margin-top: -2px;
+  margin-bottom: -4px;
+  color: #08514c;
+  font-size: 19px;
+  font-weight: 400;
+  line-height: 1.1;
+  vertical-align: middle;
 }
 
 .metrics-grid {
   display: grid;
-
   grid-template-columns: repeat(5, minmax(0, 1fr));
-
   gap: 12px;
+  min-width: 0;
 }
 
 .dashboard-panels {
   display: grid;
-
   grid-template-columns: repeat(2, minmax(0, 1fr));
-
-  gap:         16px;
+  gap: 16px;
   align-items: start;
+  min-width: 0;
 }
 
 .panel-card {
-  padding:        18px;
-  border-radius:  14px;
-  background:     #ffffff;
-  border:         1px solid #d9e3e7;
-  min-height:     300px;
+  padding: 18px;
+  border-radius: 14px;
+  background: #ffffff;
+  border: 1px solid #d9e3e7;
+  min-height: 300px;
+  min-width: 0;
   vertical-align: middle;
 }
 
@@ -730,9 +769,9 @@ onMounted(async () => {
 
 .panel-title {
   margin-bottom: 16px;
-  font-size:     19px;
-  font-weight:   400;
-  color:         #08514c;
+  font-size: 19px;
+  font-weight: 400;
+  color: #08514c;
 }
 
 .owner-dashboard-page :deep(.text-h6.text-bold) {
@@ -758,8 +797,34 @@ onMounted(async () => {
 }
 
 @media (max-width: 640px) {
+  .dashboard-shell {
+    gap: 12px;
+  }
+
+  .dashboard-section-caption {
+    font-size: 16px;
+    line-height: 1.3;
+    margin-bottom: 0;
+  }
+
   .metrics-grid {
     grid-template-columns: 1fr;
+    gap: 10px;
+  }
+
+  .dashboard-panels {
+    gap: 12px;
+  }
+
+  .panel-card {
+    padding: 14px;
+    min-height: 0;
+    border-radius: 12px;
+  }
+
+  .panel-title {
+    margin-bottom: 12px;
+    font-size: 17px;
   }
 }
 </style>

+ 108 - 76
src/pages/dashboard/components/DashboardAvailabilityPanel.vue

@@ -1,8 +1,5 @@
 <template>
-  <q-card
-    class="panel-card panel-card--soft"
-    flat
-  >
+  <q-card class="panel-card panel-card--soft" flat>
     <div class="panel-title">Disponibilidade do Período</div>
 
     <div class="availability-list">
@@ -18,16 +15,25 @@
               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">
+            <div class="availability-track-meta">
+              <div class="availability-bar-label-wrap">
                 <span
                   class="availability-bar-value-dot"
-                  :style="{ backgroundColor: item.textColor }"
+                  :style="{
+                    color: item.textColor,
+                    backgroundColor: item.textColor,
+                  }"
                 />
 
-                <span class="availability-bar-value">{{ item.valueLabel }}</span>
+                <span class="availability-bar-label">{{ item.label }}</span>
+              </div>
+
+              <span class="availability-bar-value-wrap">
+                <span
+                  class="availability-bar-value"
+                  :style="{ color: item.textColor }"
+                  >{{ item.valueLabel }}</span
+                >
               </span>
             </div>
           </div>
@@ -40,7 +46,8 @@
         v-for="item in items"
         :key="`${item.label}-total`"
         :style="{
-          width: `${item.percentage}%`, backgroundColor: item.color,
+          width: `${item.percentage}%`,
+          backgroundColor: item.color,
         }"
         class="availability-total-segment"
       />
@@ -58,15 +65,15 @@ import { formatInteger } from "src/helpers/utils";
 
 const props = defineProps({
   isAllPropertiesSelected: {
-    type:    Boolean,
+    type: Boolean,
     default: false,
   },
   items: {
-    type:    Array,
+    type: Array,
     default: () => [],
   },
   totalCapacityDays: {
-    type:    Number,
+    type: Number,
     default: 0,
   },
 });
@@ -86,8 +93,12 @@ const darkenColor = (hexColor, amount = 0.42) => {
     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))));
+  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("")}`;
 };
@@ -102,11 +113,11 @@ const decoratedItems = computed(() =>
 
 <style scoped lang="scss">
 .panel-card {
-  padding:        18px;
-  border-radius:  14px;
-  background:     #ffffff;
-  border:         1px solid #d9e3e7;
-  min-height:     300px;
+  padding: 18px;
+  border-radius: 14px;
+  background: #ffffff;
+  border: 1px solid #d9e3e7;
+  min-height: 300px;
   vertical-align: middle;
 }
 
@@ -116,114 +127,135 @@ const decoratedItems = computed(() =>
 
 .panel-title {
   margin-bottom: 16px;
-  font-size:     19px;
-  font-weight:   400;
-  color:         #08514c;
+  font-size: 19px;
+  font-weight: 400;
+  color: #08514c;
 }
 
 .availability-list {
-  display:        flex;
+  display: flex;
   flex-direction: column;
-  gap:            16px;
+  gap: 16px;
 }
 
 .availability-item {
-  display:        flex;
+  display: flex;
   flex-direction: column;
-  gap:            10px;
+  gap: 10px;
 }
 
 .availability-track {
-  position:       relative;
-  width:          100%;
-  min-height:     42px;
-  overflow:       hidden;
-  background:     #dde5e8;
+  position: relative;
+  width: 100%;
+  min-height: 42px;
+  overflow: hidden;
+  background: #dde5e8;
   vertical-align: middle;
 }
 
 .availability-bar {
-  width:          100%;
-  min-height:     42px;
+  width: 100%;
+  min-height: 42px;
   vertical-align: middle;
 }
 
 .availability-track-meta {
-  display:         flex;
-  align-items:     center;
+  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;
+  gap: 12px;
+  width: 100%;
+  min-width: 0;
+  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;
+  text-align: left;
   vertical-align: middle;
 }
 
-.availability-bar-value {
-  text-align:     right;
+.availability-bar-label-wrap,
+.availability-bar-value-wrap {
+  display: inline-flex;
+  align-items: center;
+  justify-content: flex-start;
+  gap: 8px;
+  min-width: 0;
   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 {
+  text-align: right;
+  overflow-wrap: anywhere;
+  vertical-align: middle;
 }
 
 .availability-bar-value-dot {
-  display:        inline-flex;
-  width:          8px;
-  height:         8px;
-  border-radius:  50%;
+  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;
+  display: flex;
+  width: 100%;
+  height: 18px;
+  margin-top: 18px;
+  overflow: hidden;
+  background: #dde5e8;
   vertical-align: middle;
 }
 
 .availability-total-segment {
-  height:         100%;
-  min-width:      0;
+  height: 100%;
+  min-width: 0;
   vertical-align: middle;
 }
 
 .availability-total {
   margin-top: 18px;
-  color:      #657177;
-  font-size:  14px;
+  color: #657177;
+  font-size: 14px;
   text-align: center;
 }
 
 @media (max-width: 640px) {
+  .availability-list {
+    gap: 12px;
+  }
+
+  .availability-track,
   .availability-bar {
-    min-height: 42px;
+    min-height: 48px;
   }
 
   .availability-track-meta {
-    gap:        8px;
-    min-height: 42px;
-    padding:    0 12px;
-    font-size:  12px;
+    flex-direction: column;
+    align-items: flex-start;
+    justify-content: center;
+    gap: 8px;
+    min-height: 48px;
+    padding: 0 12px;
+    font-size: 12px;
+    white-space: normal;
+  }
+
+  .availability-bar-value-wrap {
+    width: 100%;
+  }
+
+  .availability-bar-value {
+    text-align: left;
   }
 }
 </style>

+ 63 - 32
src/pages/dashboard/components/DashboardChannelsPanel.vue

@@ -33,7 +33,8 @@ import { formatInteger } from "src/helpers/utils";
 
 const props = defineProps({
   channels: {
-    type: Array, default: () => [],
+    type: Array,
+    default: () => [],
   },
 });
 
@@ -62,11 +63,11 @@ const normalize = (channel) => {
   }
 
   if (
-    value.includes("direto")
-    || value.includes("direct")
-    || value.includes("site")
-    || value.includes("whatsapp")
-    || value.includes("instagram")
+    value.includes("direto") ||
+    value.includes("direct") ||
+    value.includes("site") ||
+    value.includes("whatsapp") ||
+    value.includes("instagram")
   ) {
     return "direto";
   }
@@ -89,10 +90,10 @@ const resolveChannelColor = (index) =>
 
 const legendItems = computed(() =>
   props.channels.map((c, index) => ({
-    key:                `${normalize(c.channel)}-${index}`,
-    label:              c.channel,
+    key: `${normalize(c.channel)}-${index}`,
+    label: c.channel,
     reservations_count: Number(c.reservations_count ?? 0),
-    color:              resolveChannelColor(index),
+    color: resolveChannelColor(index),
   })),
 );
 
@@ -129,60 +130,90 @@ const chartOptions = computed(() => ({
 <style scoped>
 .panel-title {
   margin-bottom: 16px;
-  font-size:     19px;
-  font-weight:   400;
-  color:         #08514c;
+  font-size: 19px;
+  font-weight: 400;
+  color: #08514c;
 }
 
 .channel-chart-wrap {
-  display:         flex;
+  display: flex;
   justify-content: center;
-  padding:         8px 0 16px;
+  min-width: 0;
+  padding: 8px 0 16px;
 }
 
 .channel-chart-box {
-  width:         min(120px, 100%);
-  height:        120px;
+  width: min(120px, 100%);
+  height: 120px;
   margin-bottom: 74px;
 }
 
 .channel-legend {
   display: grid;
-
   grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
-
-  gap:           14px;
+  gap: 14px;
   justify-items: center;
-  text-align:    center;
+  text-align: center;
+  min-width: 0;
 }
 
 .channel-legend-item {
-  display:        flex;
+  display: flex;
   flex-direction: column;
-  align-items:    center;
-  gap:            4px;
+  align-items: center;
+  gap: 4px;
+  min-width: 0;
   vertical-align: middle;
 }
 
 .channel-dot {
-  display:       inline-flex;
-  width:         10px;
-  height:        10px;
+  display: inline-flex;
+  width: 10px;
+  height: 10px;
   border-radius: 50%;
 }
 
 .channel-name {
-  display:     inline-flex;
+  display: inline-flex;
   align-items: center;
-  gap:         8px;
-  font-size:   15px;
+  gap: 8px;
+  font-size: 15px;
   font-weight: 600;
-  color:       #273136;
+  color: #273136;
+  min-width: 0;
+  overflow-wrap: anywhere;
+  text-align: center;
 }
 
 .channel-total {
-  font-size:   14px;
+  font-size: 14px;
   font-weight: 400;
-  color:       #5f6d73;
+  color: #5f6d73;
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 640px) {
+  .channel-chart-wrap {
+    padding: 0 0 12px;
+  }
+
+  .channel-chart-box {
+    width: min(160px, 100%);
+    height: 160px;
+    margin-bottom: 24px;
+  }
+
+  .channel-legend {
+    grid-template-columns: minmax(0, 1fr);
+    gap: 12px;
+  }
+
+  .channel-name {
+    font-size: 14px;
+  }
+
+  .channel-total {
+    font-size: 13px;
+  }
 }
 </style>

+ 43 - 32
src/pages/dashboard/components/DashboardFiltersBar.vue

@@ -72,80 +72,78 @@
 <script setup>
 defineProps({
   canExportReport: {
-    type:    Boolean,
+    type: Boolean,
     default: false,
   },
   exporting: {
-    type:    Boolean,
+    type: Boolean,
     default: false,
   },
   loading: {
-    type:    Boolean,
+    type: Boolean,
     default: false,
   },
   monthOptions: {
-    type:    Array,
+    type: Array,
     default: () => [],
   },
   propertyOptions: {
-    type:    Array,
+    type: Array,
     default: () => [],
   },
   selectedMonth: {
-    type:    Number,
+    type: Number,
     default: null,
   },
   selectedPropertyOption: {
-    type:    Object,
+    type: Object,
     default: null,
   },
   selectedYear: {
-    type:    Number,
+    type: Number,
     default: null,
   },
   yearOptions: {
-    type:    Array,
+    type: Array,
     default: () => [],
   },
 });
 
-defineEmits([
-  "export",
-  "update:month",
-  "update:property",
-  "update:year",
-]);
+defineEmits(["export", "update:month", "update:property", "update:year"]);
 </script>
 
 <style scoped lang="scss">
 .dashboard-header {
-  display:         flex;
-  align-items:     center;
+  display: flex;
+  align-items: center;
   justify-content: space-between;
-  gap:             16px;
-  flex-wrap:       wrap;
-  vertical-align:  middle;
+  gap: 16px;
+  flex-wrap: wrap;
+  min-width: 0;
+  vertical-align: middle;
 }
 
 .dashboard-title {
-  font-size:   28px;
+  font-size: 28px;
   font-weight: 700;
-  color:       #202427;
+  color: #202427;
   line-height: 1.1;
 }
 
 .dashboard-filters {
-  display:         flex;
-  align-items:     center;
+  display: flex;
+  align-items: center;
   justify-content: flex-end;
-  gap:             12px;
-  flex-wrap:       wrap;
-  margin-left:     auto;
-  vertical-align:  middle;
+  gap: 12px;
+  flex-wrap: wrap;
+  margin-left: auto;
+  min-width: 0;
+  vertical-align: middle;
 }
 
 .dashboard-filter {
   min-width: 120px;
+  max-width: 100%;
 }
 
 .dashboard-filter--property {
@@ -158,14 +156,22 @@ defineEmits([
 }
 
 .dashboard-export-btn {
-  height:         40px;
+  height: 40px;
   vertical-align: middle;
 }
 
 @media (max-width: 640px) {
+  .dashboard-header {
+    gap: 12px;
+  }
+
+  .dashboard-title {
+    font-size: 24px;
+  }
+
   .dashboard-filters {
-    width:           100%;
-    margin-left:     0;
+    width: 100%;
+    margin-left: 0;
     justify-content: stretch;
   }
 
@@ -173,8 +179,13 @@ defineEmits([
   .dashboard-filter--property,
   .dashboard-filter--month,
   .dashboard-filter--year {
-    width:     100%;
+    width: 100%;
     min-width: 100%;
   }
+
+  .dashboard-export-btn {
+    width: 100%;
+    max-width: 100%;
+  }
 }
 </style>

+ 41 - 18
src/pages/dashboard/components/DashboardMetricCard.vue

@@ -16,25 +16,26 @@
 
 <script setup>
 defineProps({
-  caption: { type: String, default:  "" },
-  label:   { type: String, required: true },
-  value:   { type: String, required: true },
+  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;
-  border-radius:   12px;
-  background:      #f0f3f5;
-  vertical-align:  middle;
+  gap: 6px;
+  min-height: 106px;
+  min-width: 0;
+  padding: 16px;
+  border: 1px solid #d9e3e7;
+  border-radius: 12px;
+  background: #f0f3f5;
+  vertical-align: middle;
 }
 
 .metric-card--primary {
@@ -42,23 +43,45 @@ defineProps({
 }
 
 .metric-card--secondary {
-  border-top: 3px solid #84A8A6;
+  border-top: 3px solid #84a8a6;
 }
 
 .metric-label {
-  font-size:   19px;
+  font-size: 19px;
   font-weight: 400;
-  color:       #4D4E4E;
+  color: #4d4e4e;
+  overflow-wrap: anywhere;
 }
 
 .metric-value {
-  font-size:   24px;
+  font-size: 24px;
   line-height: 1.15;
-  color:       #111212;
+  color: #111212;
+  overflow-wrap: anywhere;
 }
 
 .metric-caption {
   font-size: 14px;
-  color:     #4D4E4E;
+  color: #4d4e4e;
+  overflow-wrap: anywhere;
+}
+
+@media (max-width: 640px) {
+  .metric-card {
+    min-height: 0;
+    padding: 14px;
+  }
+
+  .metric-label {
+    font-size: 16px;
+  }
+
+  .metric-value {
+    font-size: 21px;
+  }
+
+  .metric-caption {
+    font-size: 13px;
+  }
 }
 </style>

+ 39 - 18
src/pages/dashboard/components/DashboardPayoutTable.vue

@@ -10,13 +10,10 @@
       </thead>
 
       <tbody>
-        <tr
-          v-for="item in rows"
-          :key="item.property_id"
-        >
+        <tr v-for="item in rows" :key="item.property_id">
           <td>{{ item.property_label }}</td>
           <td>{{ formatInteger(item.reservations_count) }}</td>
-          <td>{{ formatCurrency(item.owner_payout_amount) }}</td>
+          <td>{{ formatCurrency(item.final_payout_amount) }}</td>
         </tr>
 
         <tr class="payout-total-row">
@@ -32,21 +29,22 @@
 <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));
@@ -63,16 +61,16 @@ const formatInteger = (value) => {
 }
 
 .payout-table {
-  width:           100%;
+  width: 100%;
   border-collapse: collapse;
 }
 
 .payout-table th,
 .payout-table td {
-  padding:        10px 16px;
-  border-bottom:  1px solid rgba(8, 81, 76, 0.1);
-  color:          #273136;
-  text-align:     left;
+  padding: 10px 16px;
+  border-bottom: 1px solid #aaaaaa;
+  color: #273136;
+  text-align: left;
   vertical-align: middle;
 }
 
@@ -87,14 +85,37 @@ const formatInteger = (value) => {
 }
 
 .payout-table th {
-  font-size:   17px !important;
+  font-size: 17px !important;
   font-weight: 700;
-  color:       #232323;
+  color: #232323;
 }
 
 .payout-total-row td {
-  font-size:     17px !important;
-  font-weight:   700;
+  font-size: 17px !important;
+  font-weight: 700;
   border-bottom: 0;
+  color: #4d4e4e;
+}
+
+@media (max-width: 640px) {
+  .payout-table {
+    min-width: 0;
+  }
+
+  .payout-table th,
+  .payout-table td {
+    padding: 8px 12px;
+    font-size: 13px;
+  }
+
+  .payout-table th:first-child,
+  .payout-table td:first-child {
+    min-width: 132px;
+  }
+
+  .payout-table th,
+  .payout-total-row td {
+    font-size: 14px !important;
+  }
 }
 </style>

+ 17 - 11
src/pages/dashboard/components/DashboardRevenuePanel.vue

@@ -13,11 +13,11 @@ import { Line } from "vue-chartjs";
 
 defineProps({
   chartData: {
-    type:     Object,
+    type: Object,
     required: true,
   },
   chartOptions: {
-    type:     Object,
+    type: Object,
     required: true,
   },
 });
@@ -25,19 +25,19 @@ defineProps({
 
 <style scoped lang="scss">
 .panel-card {
-  padding:        18px;
-  border-radius:  14px;
-  background:     #ffffff;
-  border:         1px solid #d9e3e7;
-  min-height:     300px;
+  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;
+  font-size: 19px;
+  font-weight: 400;
+  color: #08514c;
 }
 
 .panel-card--soft {
@@ -45,7 +45,13 @@ defineProps({
 }
 
 .line-chart-wrap {
-  height:         235px;
+  height: 235px;
   vertical-align: middle;
 }
+
+@media (max-width: 640px) {
+  .line-chart-wrap {
+    height: 220px;
+  }
+}
 </style>

+ 209 - 0
src/pages/profile/ProfilePage.vue

@@ -0,0 +1,209 @@
+<template>
+  <q-page class="column q-gutter-md">
+    <DefaultHeaderPage />
+
+    <q-card bordered class="q-pa-md" flat style="border-radius: 10px">
+      <div class="column q-gutter-md">
+        <div class="column q-gutter-sm">
+          <div class="text-subtitle1 text-weight-bold text-primary">
+            Dados pessoais
+          </div>
+
+          <q-separator />
+
+          <div class="grid-2">
+            <q-input
+              :model-value="userData.name"
+              bg-color="white"
+              label="Nome"
+              outlined
+              readonly
+            />
+
+            <q-input
+              :model-value="userData.document"
+              bg-color="white"
+              label="CPF/CNPJ"
+              outlined
+              readonly
+            />
+          </div>
+        </div>
+
+        <div class="column q-gutter-sm">
+          <div class="text-subtitle1 text-weight-bold text-primary">Acesso</div>
+
+          <q-separator />
+
+          <div class="grid-3">
+            <q-input
+              :model-value="userData.email"
+              bg-color="white"
+              label="Email"
+              outlined
+              readonly
+            />
+
+            <q-input
+              bg-color="white"
+              label="Senha"
+              outlined
+              readonly
+              type="password"
+            />
+
+            <q-input
+              bg-color="white"
+              label="Confirmar Senha"
+              outlined
+              readonly
+              type="password"
+            />
+          </div>
+        </div>
+
+        <div class="row justify-end">
+          <q-btn color="primary" label="Alterar" @click="openEditDialog" />
+        </div>
+      </div>
+
+      <q-inner-loading :showing="loading">
+        <q-spinner color="primary" size="40px" />
+      </q-inner-loading>
+    </q-card>
+
+    <ProfileEditDialog
+      v-model="editDialog"
+      :form="editForm"
+      :input-rules="inputRules"
+      :server-errors="serverErrors"
+      :submitting="submitting"
+      @close="closeEditDialog"
+      @submit="submitProfileUpdate"
+    />
+  </q-page>
+</template>
+
+<script setup>
+import { computed, onMounted, reactive, ref } from "vue";
+import { updateUser } from "src/api/user";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { userStore } from "src/stores/user";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import ProfileEditDialog from "./components/ProfileEditDialog.vue";
+
+defineOptions({ name: "ProfilePage" });
+
+const { inputRules } = useInputRules();
+const currentUserStore = userStore();
+
+const {
+  execute,
+  loading: submitting,
+  serverErrors,
+} = useSubmitHandler(() => closeEditDialog());
+
+const loading = ref(false);
+const editDialog = ref(false);
+
+const editForm = reactive({
+  name: "",
+  document: "",
+  email: "",
+  password: "",
+  confirmPassword: "",
+});
+
+const userData = computed(() => {
+  const user = currentUserStore.user ?? {};
+  return {
+    id: user?.id ?? null,
+    name: user?.name ?? user?.full_name ?? "",
+    document:
+      user?.document_number ??
+      user?.cpf_cnpj ??
+      user?.cpf ??
+      user?.document ??
+      "",
+    email: user?.email ?? "",
+  };
+});
+
+const loadUser = async () => {
+  loading.value = true;
+  try {
+    await currentUserStore.fetchUser();
+  } finally {
+    loading.value = false;
+  }
+};
+
+const resetEditForm = () => {
+  Object.assign(editForm, {
+    name: userData.value.name,
+    document: userData.value.document,
+    email: userData.value.email,
+    password: "",
+    confirmPassword: "",
+  });
+  serverErrors.value = {};
+};
+
+const openEditDialog = () => {
+  resetEditForm();
+  editDialog.value = true;
+};
+
+const closeEditDialog = () => {
+  editDialog.value = false;
+  resetEditForm();
+};
+
+const buildPayload = (formValues) => {
+  const payload = {
+    name: formValues.name,
+    email: formValues.email,
+    document_number: formValues.document,
+  };
+
+  if (formValues.password) {
+    payload.password = formValues.password;
+  }
+
+  return payload;
+};
+
+const submitProfileUpdate = async (formValues) => {
+  if (!userData.value.id) return;
+
+  await execute(async () => {
+    await updateUser(buildPayload(formValues), userData.value.id);
+    await loadUser();
+  });
+};
+
+onMounted(loadUser);
+</script>
+
+<style scoped>
+.grid-2 {
+  display: grid;
+  grid-template-columns: 1fr 240px;
+  gap: 12px;
+}
+
+.grid-3 {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+}
+
+@media (max-width: 900px) {
+  .grid-2,
+  .grid-3 {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 197 - 0
src/pages/profile/components/ProfileEditDialog.vue

@@ -0,0 +1,197 @@
+<template>
+  <q-dialog v-model="dialogModel" persistent>
+    <q-card style="max-width: 100%; border-radius: 10px">
+      <DefaultDialogHeader title="Editar meus dados" @close="closeDialog" />
+
+      <q-form
+        ref="editFormRef"
+        class="column q-gutter-sm q-pa-md bg-grey-2"
+        @submit="submitForm"
+      >
+        <div
+          class="bg-white radius-8 q-pa-md q-mb-xl"
+          style="border: 1px solid #ccc"
+        >
+          <div class="column q-gutter-sm q-pa-sm" style="border-radius: 8px">
+            <div class="text-subtitle2 text-weight-bold text-primary">
+              Dados pessoais
+            </div>
+
+            <q-separator />
+
+            <div class="grid-2">
+              <q-input
+                v-model="localForm.name"
+                bg-color="white"
+                label="Nome"
+                outlined
+                :error="Boolean(serverErrors.name)"
+                :error-message="serverErrors.name"
+                :loading="submitting"
+                :rules="[inputRules.required]"
+              />
+
+              <q-input
+                v-model="localForm.document"
+                bg-color="white"
+                label="CPF/CNPJ"
+                outlined
+                :error="Boolean(serverErrors.document_number)"
+                :error-message="serverErrors.document_number"
+                :loading="submitting"
+              />
+            </div>
+          </div>
+
+          <div class="column q-gutter-sm q-pa-sm" style="border-radius: 8px">
+            <div class="text-subtitle2 text-weight-bold text-primary">
+              Acesso
+            </div>
+
+            <q-separator />
+
+            <div class="grid-3">
+              <q-input
+                v-model="localForm.email"
+                bg-color="white"
+                label="Email"
+                outlined
+                :error="Boolean(serverErrors.email)"
+                :error-message="serverErrors.email"
+                :loading="submitting"
+                :rules="[inputRules.required, inputRules.email]"
+              />
+
+              <DefaultPasswordInput
+                v-model="localForm.password"
+                bg-color="white"
+                label="Senha"
+                outlined
+                :error="Boolean(serverErrors.password)"
+                :error-message="serverErrors.password"
+                :loading="submitting"
+                :rules="passwordRules"
+              />
+
+              <DefaultPasswordInput
+                v-model="localForm.confirmPassword"
+                bg-color="white"
+                label="Confirmar Senha"
+                outlined
+                :loading="submitting"
+                :rules="confirmPasswordRules"
+              />
+            </div>
+          </div>
+        </div>
+
+        <div class="row justify-end q-gutter-sm">
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            outline
+            @click="closeDialog"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            type="submit"
+            :loading="submitting"
+          />
+        </div>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, reactive, ref, watch } from "vue";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+
+const dialogModel = defineModel({ default: false });
+
+const emit = defineEmits(["submit", "close"]);
+
+const { form, inputRules, serverErrors, submitting } = defineProps({
+  // eslint-disable-next-line vue/require-default-prop
+  form: Object,
+  // eslint-disable-next-line vue/require-default-prop
+  inputRules: Object,
+  serverErrors: { type: Object, default: () => ({}) },
+  submitting: Boolean,
+});
+
+const editFormRef = ref(null);
+
+const localForm = reactive({
+  name: "",
+  document: "",
+  email: "",
+  password: "",
+  confirmPassword: "",
+});
+
+const passwordRules = computed(() =>
+  localForm.password ? [inputRules.min(6)] : [],
+);
+
+const confirmPasswordRules = computed(() =>
+  localForm.password
+    ? [inputRules.required, inputRules.samePassword(localForm.password)]
+    : [],
+);
+
+const syncLocalForm = () => {
+  Object.assign(localForm, {
+    name: form.name ?? "",
+    document: form.document ?? "",
+    email: form.email ?? "",
+    password: form.password ?? "",
+    confirmPassword: form.confirmPassword ?? "",
+  });
+};
+
+watch(
+  () => [dialogModel.value, form],
+  () => {
+    if (dialogModel.value) syncLocalForm();
+  },
+  { deep: true, immediate: true },
+);
+
+const closeDialog = () => {
+  dialogModel.value = false;
+  emit("close");
+};
+
+const submitForm = async () => {
+  const isValid = await editFormRef.value?.validate();
+  if (!isValid) return;
+
+  emit("submit", { ...localForm });
+};
+</script>
+
+<style scoped>
+.grid-2 {
+  display: grid;
+  grid-template-columns: 1fr 240px;
+  gap: 12px;
+}
+
+.grid-3 {
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 12px;
+}
+
+@media (max-width: 900px) {
+  .grid-2,
+  .grid-3 {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 2 - 1
src/router/index.js

@@ -59,7 +59,8 @@ export default route(function (/* { store, ssrContext } */) {
 
       if (!permission) {
         Notify.create({
-          message: useI18n().t("validation.permissions.view"), type: "negative",
+          message: useI18n().t("validation.permissions.view"),
+          type: "negative",
         });
 
         return next(from);

+ 36 - 3
src/router/routes.js

@@ -10,7 +10,8 @@ const routes = [
 
     children: [
       {
-        path: "", redirect: { name: "DashboardPage" },
+        path: "",
+        redirect: { name: "DashboardPage" },
       },
     ],
   },
@@ -28,11 +29,12 @@ const routes = [
         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",
             },
           ],
@@ -41,6 +43,37 @@ const routes = [
     ],
   },
 
+  {
+    path: "/perfil",
+
+    component: () => import("layouts/MainLayout.vue"),
+
+    children: [
+      {
+        path: "",
+        name: "ProfilePage",
+
+        component: () => import("pages/profile/ProfilePage.vue"),
+
+        meta: {
+          requireAuth: true,
+          title: "ui.navigation.profile",
+
+          breadcrumbs: [
+            {
+              name: "DashboardPage",
+              title: "ui.navigation.dashboard",
+            },
+            {
+              name: "ProfilePage",
+              title: "ui.navigation.profile",
+            },
+          ],
+        },
+      },
+    ],
+  },
+
   {
     path: "/login",
 

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

@@ -12,15 +12,16 @@ export default [
         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",
             },
           ],

+ 5 - 5
src/stores/index.js

@@ -1,5 +1,5 @@
-import { store } from 'quasar/wrappers'
-import { createPinia } from 'pinia'
+import { store } from "quasar/wrappers";
+import { createPinia } from "pinia";
 
 /*
  * If not building with SSR mode, you can
@@ -11,10 +11,10 @@ import { createPinia } from 'pinia'
  */
 
 export default store((/* { ssrContext } */) => {
-  const pinia = createPinia()
+  const pinia = createPinia();
 
   // You can add Pinia plugins here
   // pinia.use(SomePiniaPlugin)
 
-  return pinia
-})
+  return pinia;
+});

+ 13 - 5
src/stores/navigation.js

@@ -4,11 +4,19 @@ 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: "ui.navigation.dashboard",
+      name: "DashboardPage",
+      icon: "mdi-monitor-dashboard",
+      disable: false,
+      permission: true,
+    },
+    {
+      type: "single",
+      title: "ui.navigation.profile",
+      name: "ProfilePage",
+      icon: "mdi-account-multiple-outline",
+      disable: false,
       permission: true,
     },
   ]);

+ 13 - 13
src/stores/permission.js

@@ -6,31 +6,31 @@ 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(() =>

+ 3 - 3
src/stores/user.js

@@ -3,7 +3,7 @@ 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);
 
@@ -14,13 +14,13 @@ export const userStore = defineStore("user", () => {
   };
 
   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;
   };