فهرست منبع

Merge remote-tracking branch 'refs/remotes/origin/master'

Denis 10 ماه پیش
والد
کامیت
baa7ac6882
40فایلهای تغییر یافته به همراه4092 افزوده شده و 514 حذف شده
  1. 682 140
      package-lock.json
  2. 20 13
      package.json
  3. 3 5
      quasar.config.js
  4. 40 5
      src/api/cacheService.js
  5. 2 0
      src/api/index.js
  6. 1 1
      src/api/permission.js
  7. 3 3
      src/api/user.js
  8. 2 4
      src/boot/defaultPropsComponents.js
  9. 93 3
      src/boot/socket.io.js
  10. 36 56
      src/components/charts/CardIconChart.vue
  11. 81 0
      src/components/charts/CardIconMiniChart.vue
  12. 285 0
      src/components/charts/custom/NPSChart.vue
  13. 201 0
      src/components/charts/custom/SpeedometerChart.vue
  14. 41 0
      src/components/charts/maps/Brasil/DadosBrasil.js
  15. 228 0
      src/components/charts/maps/Brasil/MapaBrasil.vue
  16. 110 0
      src/components/charts/maps/MapaEstado.vue
  17. 14 0
      src/components/charts/maps/Paraguai/DadosParaguai.js
  18. 225 0
      src/components/charts/maps/Paraguai/MapaParaguai.vue
  19. 23 35
      src/components/charts/mini/MiniBarChart.vue
  20. 28 35
      src/components/charts/mini/MiniLineChart.vue
  21. 267 0
      src/components/charts/normal/BarChart.vue
  22. 204 0
      src/components/charts/normal/DoughnutChart.vue
  23. 265 0
      src/components/charts/normal/LineChart.vue
  24. 205 0
      src/components/charts/normal/PieChart.vue
  25. 3 3
      src/components/defaults/DefaultCurrencyInput.vue
  26. 77 30
      src/components/defaults/DefaultTable.vue
  27. 4 2
      src/components/layout/DefaultHeaderPage.vue
  28. 70 64
      src/components/layout/LeftMenuLayout.vue
  29. 1 9
      src/components/regions/CountrySelect.vue
  30. 1 1
      src/composables/useAuth.js
  31. 87 11
      src/composables/useFormUpdateTracker.js
  32. 70 8
      src/composables/useInputRules.js
  33. 7 0
      src/css/app.scss
  34. 2 1
      src/helpers/utils.js
  35. 76 1
      src/i18n/locales/en.json
  36. 134 59
      src/i18n/locales/es.json
  37. 86 11
      src/i18n/locales/pt.json
  38. 5 5
      src/layouts/MainLayout.vue
  39. 366 9
      src/pages/dashboard/DashboardPage.vue
  40. 44 0
      src/pages/dashboard/components/DatePeriodSelector.vue

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 682 - 140
package-lock.json


+ 20 - 13
package.json

@@ -14,31 +14,38 @@
     "build": "quasar build"
   },
   "dependencies": {
-    "@quasar/extras": "^1.16.17",
-    "axios": "^1.8.4",
-    "chart.js": "^4.4.8",
-    "date-fns": "^4.1.0",
-    "pinia": "^3.0.1",
-    "quasar": "^2.18.1",
+    "@bufbuild/protobuf": "^2.5.1",
+    "@chenfengyuan/vue-qrcode": "^2.0.0",
+    "@quasar/cli": "^2.5.0",
+    "@quasar/extras": "^1.16.15",
+    "axios": "^1.7.9",
+    "chart.js": "^4.4.7",
+    "chartjs-plugin-datalabels": "^2.2.0",
+    "date-fns": "^3.6.0",
+    "pinia": "^2.3.0",
+    "qrcode": "^1.5.4",
+    "quasar": "^2.17.4",
     "socket.io-client": "^4.8.1",
-    "vue": "^3.5",
+    "vue": "^3.5.13",
     "vue-chartjs": "^5.3.2",
-    "vue-currency-input": "^3.2.1",
+    "vue-currency-input": "^3.1.0",
     "vue-i18n": "^9.14.2",
     "vue-router": "^4.5.0"
   },
   "devDependencies": {
+    "@bufbuild/buf": "^1.54.0",
+    "@bufbuild/protoc-gen-es": "^2.5.1",
     "@intlify/eslint-plugin-vue-i18n": "^3.2.0",
     "@intlify/unplugin-vue-i18n": "^2.0.0",
     "@intlify/vue-i18n-loader": "^4.2.0",
-    "@quasar/app-vite": "^2.2.0",
-    "autoprefixer": "^10.4.21",
+    "@quasar/app-vite": "^2.0.1",
+    "autoprefixer": "^10.4.20",
     "eslint": "^8.57.1",
     "eslint-config-prettier": "^9.1.0",
     "eslint-plugin-vue": "^9.32.0",
-    "postcss": "^8.5.3",
-    "prettier": "^3.5.3",
-    "vite-plugin-checker": "^0.9.1"
+    "postcss": "^8.4.49",
+    "prettier": "^3.4.2",
+    "vite-plugin-checker": "^0.6.4"
   },
   "engines": {
     "node": "^24 || ^22 || ^20 || ^18",

+ 3 - 5
quasar.config.js

@@ -32,7 +32,7 @@ export default configure((ctx) => {
       // 'fontawesome-v6',
       // 'eva-icons',
       // 'themify',
-      "line-awesome",
+      // "line-awesome",
       // 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
 
       "roboto-font", // optional, you are not bound to it
@@ -43,7 +43,7 @@ export default configure((ctx) => {
     build: {
       target: {
         browser: ["es2022", "firefox115", "chrome115", "safari14"],
-        node: "node20",
+        node: "node22",
       },
 
       vueRouterMode: "history", // available values: 'hash', 'history'
@@ -56,9 +56,7 @@ export default configure((ctx) => {
       // publicPath: '/',
       // analyze: true,
       env: {
-        API_URL: ctx.dev
-          ? "http://localhost:8000"
-          : "http://localhost:8000",
+        API_URL: ctx.dev ? "http://localhost:8000" : "http://localhost:8000",
         PASSWORD: ctx.dev ? "S@ft2080." : "",
         WEBSOCKET_API: ctx.dev
           ? "http://localhost:4321/"

+ 40 - 5
src/api/cacheService.js

@@ -1,4 +1,4 @@
-import { api } from "src/boot/axios";
+import api from "src/api";
 
 const createDbConnection = (dbName = "apiCache", version = 1) => {
   let db = null;
@@ -91,6 +91,34 @@ const clearCache = async (cacheKey) => {
   });
 };
 
+const isCacheableValue = (value) => {
+  if (value === null) {
+    return false;
+  }
+
+  if (
+    typeof value === "object" &&
+    Object.keys(value).length === 1 &&
+    Object.prototype.hasOwnProperty.call(value, "data")
+  ) {
+    return false;
+  }
+
+  if (Array.isArray(value) && value.length === 0) {
+    return false;
+  }
+
+  if (
+    typeof value === "object" &&
+    !Array.isArray(value) &&
+    Object.keys(value).length === 0
+  ) {
+    return false;
+  }
+
+  return true;
+};
+
 export const createCachedApi = (namespace, ttl = 3600) => {
   const getCacheKey = (path) => `${namespace}:${path}`;
 
@@ -115,11 +143,18 @@ export const createCachedApi = (namespace, ttl = 3600) => {
     const key = getCacheKey(path);
     const cached = await getFromCache(key);
 
-    if (cached) return cached;
+    if (isCacheableValue(cached)) {
+      return { data: cached };
+    }
 
-    const { data } = await api.get(path);
-    await setInCache(key, { data }, options.ttl ?? ttl);
-    return { data };
+    try {
+      const { data } = await api.get(path);
+      await setInCache(key, data, options.ttl ?? ttl);
+      return { data };
+    } catch (error) {
+      console.error(`API request for ${key} failed.`, error);
+      throw error;
+    }
   };
 
   const post = async (path, payload) => {

+ 2 - 0
src/api/index.js

@@ -0,0 +1,2 @@
+import { api } from "boot/axios";
+export default api;

+ 1 - 1
src/api/permission.js

@@ -1,4 +1,4 @@
-import { api } from "src/boot/axios";
+import api from "src/api";
 
 export const getGuestPermissions = async () => {
   const { data } = await api.get("/permissions-by-type/guest");

+ 3 - 3
src/api/user.js

@@ -1,4 +1,4 @@
-import { api } from "src/boot/axios";
+import api from "src/api";
 
 export const getUser = async () => {
   const { data } = await api.get("/user/me");
@@ -23,9 +23,9 @@ export const updateUser = async (user, id) => {
 export const deleteUser = async (id) => {
   const { data } = await api.delete(`/user/${id}`);
   return data.payload;
-}
+};
 
 export const userTypes = async () => {
   const { data } = await api.get("/user-types");
   return data.payload;
-}
+};

+ 2 - 4
src/boot/defaultPropsComponents.js

@@ -1,4 +1,4 @@
-import { QDialog, QInput, QSelect, QBtn, QScrollArea, QCard } from "quasar";
+import { QDialog, QInput, QSelect, QScrollArea, QCard } from "quasar";
 import { boot } from "quasar/wrappers";
 
 /**
@@ -24,9 +24,7 @@ export default boot(() => {
   });
   SetComponentDefaults(QSelect, {
     filled: true,
-  });
-  SetComponentDefaults(QBtn, {
-    outline: true,
+    behavior: "menu",
   });
   SetComponentDefaults(QCard, {
     flat: true,

+ 93 - 3
src/boot/socket.io.js

@@ -1,5 +1,11 @@
 import { boot } from "quasar/wrappers";
 import { io } from "socket.io-client";
+import { reactive } from "vue";
+
+const state = reactive({
+  activeRooms: new Set(),
+  isConnected: false,
+});
 
 const socket = io(process.env.WEBSOCKET_API, {
   transport: ["websocket"],
@@ -12,12 +18,43 @@ const socket = io(process.env.WEBSOCKET_API, {
   timeout: 20000,
 });
 
+/**
+ * Join a room with automatic reconnection handling
+ * @param {string} roomName Room name to join
+ * @return {boolean} Success status
+ */
 const joinRoom = (roomName) => {
-  socket.emit("join", process.env.WEBSOCKET_ROOM + ":" + roomName);
+  if (!roomName) return false;
+
+  const fullRoomName = `${process.env.WEBSOCKET_ROOM}:${roomName}`;
+
+  state.activeRooms.add(fullRoomName);
+
+  if (state.isConnected) {
+    socket.emit("join", fullRoomName);
+    console.log(`Joined room: ${fullRoomName}`);
+  } else {
+    console.log(`Room ${fullRoomName} will join upon connection`);
+  }
+
+  return true;
 };
 
+/**
+ * Leave a room and clean up
+ * @param {string} roomName Room name to leave
+ */
 const leaveRoom = (roomName) => {
-  socket.emit("leave", process.env.WEBSOCKET_ROOM + ":" + roomName);
+  if (!roomName) return;
+
+  const fullRoomName = `${process.env.WEBSOCKET_ROOM}:${roomName}`;
+
+  state.activeRooms.delete(fullRoomName);
+
+  if (state.isConnected) {
+    socket.emit("leave", fullRoomName);
+    console.log(`Left room: ${fullRoomName}`);
+  }
 };
 
 const sendEventToLaravel = (eventName, data) => {
@@ -39,18 +76,32 @@ const sendEvent = (room, eventName, data) => {
 export default boot(async () => {
   socket.on("connect", () => {
     console.log("Connected to websocket server!");
+    state.isConnected = true;
+
+    // Rejoin all active rooms after reconnection
+    if (state.activeRooms.size > 0) {
+      console.log(
+        `Rejoining ${state.activeRooms.size} rooms after reconnection`,
+      );
+      state.activeRooms.forEach((room) => {
+        socket.emit("join", room);
+      });
+    }
   });
 
   socket.on("disconnect", () => {
     console.log("Disconnected from websocket server!");
+    state.isConnected = false;
   });
 
   socket.on("connect_error", (error) => {
     console.error("Websocket connection error: ", error);
+    state.isConnected = false;
   });
 
   socket.on("connect_timeout", (timeout) => {
     console.error("Websocket connection timeout: ", timeout);
+    state.isConnected = false;
   });
 
   socket.on("reconnect", (attemptNumber) => {
@@ -58,6 +109,7 @@ export default boot(async () => {
       "Reconnected to websocket server! Attempt number: ",
       attemptNumber,
     );
+    state.isConnected = true;
   });
 
   socket.on("reconnect_attempt", (attemptNumber) => {
@@ -65,4 +117,42 @@ export default boot(async () => {
   });
 });
 
-export { socket, joinRoom, leaveRoom, sendEvent, sendEventToLaravel };
+/**
+ * Helper function to create one-time event listeners with automatic cleanup
+ * @param {string} event Event name
+ * @param {Function} callback Callback function
+ * @param {string|null} roomName Optional room to leave on callback
+ * @return {Function} Cleanup function
+ */
+const onceEvent = (event, callback, roomName = null) => {
+  if (!event || typeof callback !== "function") return () => {};
+
+  const wrappedCallback = (data) => {
+    socket.off(event, wrappedCallback);
+
+    if (roomName) {
+      leaveRoom(roomName);
+    }
+
+    callback(data);
+  };
+
+  socket.on(event, wrappedCallback);
+
+  return () => {
+    socket.off(event, wrappedCallback);
+    if (roomName) {
+      leaveRoom(roomName);
+    }
+  };
+};
+
+export {
+  socket,
+  joinRoom,
+  leaveRoom,
+  sendEvent,
+  sendEventToLaravel,
+  onceEvent,
+  state,
+};

+ 36 - 56
src/components/charts/CardIconChart.vue

@@ -1,57 +1,50 @@
 <template>
-  <q-card flat class="q-pa-lg">
+  <q-card
+    flat
+    class="full-height q-pa-lg"
+    :style="{
+      minHeight: $q.screen.lt.sm ? '400px' : '600px',
+      minWidth: $q.screen.lt.sm ? '200px' : '300px',
+    }"
+  >
     <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 class="flex justify-between items-center no-wrap">
+        <span class="text-h5">{{ title }}</span>
+        <div class="flex no-wrap flex-center">
+          <div class="round background">
+            <q-icon class="q-pa-sm" :name="icon" size="24px" :color="color" />
+          </div>
+          <!-- <q-icon
+            v-if="downloadImage !== null"
+            name="mdi-dots-vertical"
+            size="sm"
+            style="width: 12px;margin-right: -12px"
+            @click="downloadImage"
+          /> -->
         </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 class="flex flex-grow flex-center" style="height: calc(100% - 30px)">
+      <template v-if="hasChartSlot">
+        <slot name="chart"></slot>
+      </template>
+      <template v-else>
+        <div v-if="!loading" class="q-my-md row justify-center full-width">
+          <div class="q-pa-md body2">
+            {{ $t("http.errors.no_records_found") }}
           </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>
+      </template>
     </div>
   </q-card>
 </template>
 
 <script setup>
-// import MiniLineChart from "./mini/MiniLineChart.vue";
-import MiniBarChart from "./mini/MiniBarChart.vue";
-const props = defineProps({
+import { useSlots } from "vue";
+
+const hasChartSlot = useSlots("chart");
+
+const { color, title, icon } = defineProps({
   color: {
     type: String,
     default: "primary",
@@ -64,19 +57,6 @@ const props = defineProps({
     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>

+ 81 - 0
src/components/charts/CardIconMiniChart.vue

@@ -0,0 +1,81 @@
+<template>
+  <q-card flat class="q-pa-lg" style="max-height: 184px;">
+    <div class="column no-wrap full-width">
+      <div class="flex justify-between items-center no-wrap">
+        <span class="text-h5">{{ title }}</span>
+        <div class="round background">
+          <q-icon class="q-pa-sm" :name="icon" size="24px" :color="color" />
+        </div>
+      </div>
+      <div class="flex no-wrap full-width justify-between q-pa-sm">
+        <div class="column flex-center">
+          <span :class="numberCard.length >= 7 ? 'text-h4' : 'text-h3'">
+            {{ numberCard }}
+          </span>
+          <div
+            v-if="numberPorcent !== null"
+            class="flex no-wrap text-subtitle2"
+            :class="numberPorcent > 0 ? 'text-positive' : 'text-negative'"
+          >
+            <q-icon
+              :name="numberPorcent > 0 ? 'mdi-arrow-up' : 'mdi-arrow-down'"
+              size="18px"
+              class="q-mr-xs"
+            />
+            {{ numberPorcent + "%" }}
+          </div>
+        </div>
+        <div class="flex justify-end" style="max-width: 120px; height: 80px">
+          <slot name="chart" :color="color" :chart-data="chartData" />
+        </div>
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+const { color, title, icon, chartData, numberCard, numberPorcent } =
+  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: String,
+      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>

+ 285 - 0
src/components/charts/custom/NPSChart.vue

@@ -0,0 +1,285 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <q-card v-bind="$attrs" class="q-px-md">
+    <q-card-section class="row justify-between">
+      <div>
+        <div class="text-h6">{{ props.title }}</div>
+        <span>{{ props.subTitle }}</span>
+      </div>
+      <q-btn
+        icon="mdi-tray-arrow-down"
+        dense
+        flat
+        class="q-my-auto"
+        @click="downloadImage"
+      />
+    </q-card-section>
+    <q-separator dark inset />
+    <div class="graph-container">
+      <div class="column">
+        <div class="col-4 row">
+          <div
+            class="bg-nps-green"
+            style="width: 1rem; min-height: max-content"
+          />
+          <div class="column q-pa-md">
+            <span class="text-bold text-h6 text-nps-green">
+              {{ $t("charts.nps.promotion_zone") }}
+            </span>
+            <span>{{ $t("charts.nps.promotion_zone_range") }}</span>
+          </div>
+        </div>
+        <div class="col-4 row">
+          <div
+            class="bg-nps-green-light"
+            style="width: 1rem; min-height: max-content"
+          />
+          <div class="column q-pa-md">
+            <span class="text-bold text-h6 text-nps-green-light">
+              {{ $t("charts.nps.quality_zone") }}
+            </span>
+            <span>{{ $t("charts.nps.quality_zone_range") }}</span>
+          </div>
+        </div>
+        <div class="col-4 row">
+          <div
+            class="bg-nps-yellow"
+            style="width: 1rem; min-height: max-content"
+          />
+          <div class="column q-pa-md">
+            <span class="text-bold text-h6 text-nps-yellow">
+              {{ $t("charts.nps.refinement_zone") }}
+            </span>
+            <span>{{ $t("charts.nps.refinement_zone_range") }}</span>
+          </div>
+        </div>
+        <div class="col-4 row">
+          <div
+            class="bg-nps-red"
+            style="width: 1rem; min-height: max-content"
+          />
+          <div class="column q-pa-md">
+            <span class="text-bold text-h6 text-nps-red">
+              {{ $t("charts.nps.critical_zone") }}</span
+            >
+            <span>{{ $t("charts.nps.critical_zone_range") }}</span>
+          </div>
+        </div>
+      </div>
+
+      <div style="display: flex; flex-direction: column; align-items: center">
+        <div style="max-height: 500px">
+          <Doughnut
+            ref="chart_ref"
+            :options="chartOptions"
+            :data="chartData"
+            :plugins="[ChartDataLabels, gaugeNeedle]"
+          />
+        </div>
+        <span>{{ titulo }}</span>
+      </div>
+
+      <div class="column q-col-gutter-md">
+        <div class="column">
+          <span>
+            {{ $t("charts.nps.promoters") }} -
+            {{ ((data.promotores / data.total) * 100).toFixed(0) }} %
+          </span>
+          <q-linear-progress
+            :value="data.promotores / data.total"
+            rounded
+            size="20px"
+            color="nps-green"
+            style="min-width: 200px"
+          />
+        </div>
+        <div class="column">
+          <span
+            >{{ $t("charts.nps.passives") }} -
+            {{ ((data.neutros / data.total) * 100).toFixed(0) }} %</span
+          >
+          <q-linear-progress
+            :value="data.neutros / data.total"
+            rounded
+            size="20px"
+            color="nps-yellow"
+            style="min-width: 200px"
+          />
+        </div>
+        <div class="column">
+          <span
+            >{{ $t("charts.nps.detractors") }} -
+            {{ ((data.detratores / data.total) * 100).toFixed(0) }} %</span
+          >
+          <q-linear-progress
+            :value="data.detratores / data.total"
+            rounded
+            size="20px"
+            color="nps-red"
+            style="min-width: 200px"
+          />
+        </div>
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { Doughnut } from "vue-chartjs";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+
+const props = defineProps({
+  title: {
+    type: String,
+    required: true,
+  },
+  value: {
+    type: Number,
+    required: true,
+  },
+  subTitle: {
+    type: String,
+    default: null,
+  },
+  data: {
+    type: Object,
+    required: true,
+  },
+});
+
+const chart_ref = ref(null);
+const titulo = ref(`Valor: ${props.data?.nps}`);
+
+const gaugeNeedle = {
+  id: "gaugeNeedle",
+  afterDatasetsDraw(chart) {
+    const { ctx, data } = chart;
+    ctx.save();
+
+    const needleValue = data.datasets[0].needleValue;
+    const xCenter = chart.getDatasetMeta(0).data[0].x;
+    const yCenter = chart.getDatasetMeta(0).data[0].y;
+    const outerRadius = chart.getDatasetMeta(0).data[0].outerRadius - 40;
+    const angle = Math.PI;
+
+    let circumference =
+      (chart.getDatasetMeta(0).data[0].circumference /
+        Math.PI /
+        data.datasets[0].data[0]) *
+      needleValue;
+
+    const needleAngleValue = circumference + 1.5;
+
+    ctx.translate(xCenter, yCenter);
+    ctx.rotate(angle * needleAngleValue);
+
+    // Draw the needle
+    ctx.beginPath();
+    ctx.strokeStyle = "grey";
+    ctx.fillStyle = "grey";
+    ctx.moveTo(0 - 4, 0);
+    ctx.lineTo(0, -outerRadius);
+    ctx.lineTo(0 + 4, 0);
+    ctx.stroke();
+    ctx.fill();
+
+    ctx.beginPath();
+    ctx.arc(0, 0, 8, 0, 2 * Math.PI);
+    ctx.fillStyle = "grey";
+    ctx.fill();
+
+    ctx.restore();
+  },
+};
+
+ChartJS.register(ArcElement, Tooltip, Legend);
+
+const chartData = ref({
+  datasets: [
+    {
+      backgroundColor: ["#d43333", "#ffbe00", "#40c56c", "#0d733e"],
+      data: [100, 50, 25, 25],
+      needleValue: Number(props.data?.nps) + 100,
+      borderColor: "transparent",
+    },
+  ],
+});
+
+const chartOptions = ref({
+  rotation: 270,
+  circumference: 180,
+  cutout: "50%",
+  plugins: {
+    tooltip: {
+      enabled: false,
+    },
+    datalabels: {
+      color: "white",
+      font: {
+        size: 14,
+        weight: "bold",
+      },
+      formatter: (value, ctx) => {
+        let valor = "";
+        switch (ctx.dataIndex) {
+          case 0:
+            valor = "-100 a 0";
+            break;
+          case 1:
+            valor = "0 a 50";
+            break;
+          case 2:
+            valor = "50 a 75";
+            break;
+          case 3:
+            valor = "75 a 100";
+            break;
+          default:
+            valor = "";
+            break;
+        }
+        return valor;
+      },
+    },
+  },
+});
+
+addEventListener("resize", () => {
+  if (chart_ref.value) {
+    chart_ref.value.chart.update();
+  }
+});
+
+const downloadImage = () => {
+  const image = chart_ref.value.chart?.toBase64Image("image/jpeg", 1);
+  base64ToJPEG(image, props.title);
+};
+</script>
+
+<style scoped>
+.graph-container {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  align-items: center;
+  justify-content: space-around;
+  gap: 1rem;
+  width: 100%;
+  max-width: 100vw;
+  padding: 1rem;
+}
+
+@media screen and (max-width: 1024px) {
+  .graph-container {
+    grid-template-columns: 1fr 1fr;
+  }
+}
+
+@media screen and (max-width: 768px) {
+  .graph-container {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 201 - 0
src/components/charts/custom/SpeedometerChart.vue

@@ -0,0 +1,201 @@
+<template>
+  <q-card v-bind="$attrs" class="q-px-md full-height column justify-between">
+    <q-card-section class="q-pa-none">
+      <div class="row justify-between no-wrap items-start q-py-md q-px-sm">
+        <div>
+          <div class="text-bold text-description q-mb-sm">
+            {{ String(props.title).toLocaleUpperCase() }}
+          </div>
+          <span>{{ props.subTitle }}</span>
+        </div>
+        <q-btn
+          icon="mdi-tray-arrow-down"
+          class="q-ml-md"
+          dense
+          flat
+          @click="downloadImage"
+        />
+      </div>
+      <q-separator dark />
+    </q-card-section>
+    <div class="graph-container">
+      <Doughnut
+        ref="chart_ref"
+        :options="chartOptions"
+        :data="chartData"
+        :plugins="[ChartDataLabels, gaugeNeedle]"
+      />
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { Doughnut } from "vue-chartjs";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+
+const props = defineProps({
+  title: {
+    type: String,
+    required: true,
+  },
+  valorAgulha: {
+    type: Number,
+    required: true,
+  },
+  subTitle: {
+    type: String,
+    default: null,
+  },
+});
+
+const chart_ref = ref(null);
+const titulo = ref(`Valor: ${props.valorAgulha}`);
+
+const gaugeNeedle = {
+  id: "gaugeNeedle",
+  afterDatasetsDraw(chart) {
+    const { ctx, data } = chart;
+    ctx.save();
+
+    const needleValue = data.datasets[0].needleValue;
+    const xCenter = chart.getDatasetMeta(0).data[0].x;
+    const yCenter = chart.getDatasetMeta(0).data[0].y;
+    const outerRadius = chart.getDatasetMeta(0).data[0].outerRadius - 40;
+    const angle = Math.PI;
+
+    let circumference =
+      (chart.getDatasetMeta(0).data[0].circumference /
+        Math.PI /
+        data.datasets[0].data[0]) *
+      needleValue;
+
+    const needleAngleValue = circumference + 1.5;
+
+    ctx.translate(xCenter, yCenter);
+    ctx.rotate(angle * needleAngleValue);
+
+    // Draw the needle
+    ctx.beginPath();
+    ctx.strokeStyle = "grey";
+    ctx.fillStyle = "grey";
+    ctx.moveTo(0 - 4, 0);
+    ctx.lineTo(0, -outerRadius);
+    ctx.lineTo(0 + 4, 0);
+    ctx.stroke();
+    ctx.fill();
+
+    ctx.beginPath();
+    ctx.arc(0, 0, 8, 0, 2 * Math.PI);
+    ctx.fillStyle = "grey";
+    ctx.fill();
+
+    ctx.restore();
+  },
+};
+
+ChartJS.register(ArcElement, Tooltip, Legend);
+
+const chartData = ref({
+  datasets: [
+    {
+      backgroundColor: [
+        "#00a550",
+        "#4dbb7e",
+        "#9ad2ad",
+        "#cce156",
+        "#fff100",
+        "#ffbe00",
+        "#ff8c00",
+        "#FC3D23",
+        "#D01616",
+        "#8A0000",
+      ],
+      data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+      needleValue: props.valorAgulha,
+      borderColor: "transparent",
+    },
+  ],
+});
+
+const chartOptions = ref({
+  rotation: 270,
+  circumference: 180,
+  cutout: "50%",
+  plugins: {
+    tooltip: {
+      enabled: false,
+    },
+    title: {
+      display: true,
+      text: titulo.value,
+      color: "#ffffff",
+      position: "bottom",
+    },
+    datalabels: {
+      color: "black",
+      font: {
+        size: 14,
+        weight: "bold",
+      },
+      formatter: (value, ctx) => {
+        const valor = ctx.dataIndex;
+        return valor;
+      },
+    },
+  },
+});
+
+addEventListener("resize", () => {
+  if (chart_ref.value) {
+    chart_ref.value.chart.update();
+  }
+});
+
+const downloadImage = () => {
+  const image = chart_ref.value.chart?.toBase64Image("image/jpeg", 1);
+  base64ToJPEG(image, props.title);
+};
+</script>
+
+<style scoped>
+.graph-container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  max-height: 300px;
+}
+
+@media screen and (max-width: 1600px) {
+  .graph-container {
+    max-height: 250px;
+  }
+}
+
+@media screen and (max-width: 1360px) {
+  .graph-container {
+    max-height: 200px;
+  }
+}
+
+@media screen and (max-width: 1130px) {
+  .graph-container {
+    max-height: 175px;
+  }
+}
+
+@media screen and (max-width: 888px) {
+  .graph-container {
+    max-height: 250px;
+  }
+}
+
+@media screen and (max-width: 650px) {
+  .graph-container {
+    max-height: 300px;
+  }
+}
+</style>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 41 - 0
src/components/charts/maps/Brasil/DadosBrasil.js


+ 228 - 0
src/components/charts/maps/Brasil/MapaBrasil.vue

@@ -0,0 +1,228 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <div>
+    <q-card style="height: 100%">
+      <q-card-section class="row justify-between">
+        <div class="column">
+          <span class="text-bold text-description q-mb-sm">
+            {{ props.title }}
+          </span>
+          <span>{{ props.subtitle }}</span>
+        </div>
+        <q-btn
+          icon="mdi-tray-arrow-down"
+          dense
+          flat
+          class="q-my-auto"
+          @click="downloadImage"
+        />
+      </q-card-section>
+
+      <q-separator inset />
+      <q-card-section>
+        <div style="display: flex; flex-direction: column">
+          <div @mouseover="hideInfoBox">
+            <svg
+              ref="ref_mapa"
+              xmlns="http://www.w3.org/2000/svg"
+              class="mapa-svg-estados"
+              viewBox="120 50 500 500"
+              @click="mapclick"
+            >
+              <g id="mapa-svg-area">
+                <MapaEstado
+                  v-for="item in items"
+                  ref="child"
+                  :key="item.uf"
+                  :item="item"
+                  :show-circle-info="showCircleInfo"
+                  :class="item.classObject"
+                  :style="item.color ? `fill: ${item.color}` : ''"
+                  @state-selected-event="onStateSelectedEvent"
+                  @state-mouse-over-event="onStateMouseOverEvent"
+                />
+              </g>
+            </svg>
+          </div>
+          <div class="bg-background--2 q-pa-md" style="border-radius: 0.5rem">
+            <div
+              v-if="selected"
+              style="display: flex; flex-direction: column; gap: 0.5rem"
+            >
+              <span>
+                <b> Estado:</b> {{ dadosEstado.name }} -
+                {{ dadosEstado.uf.toUpperCase() }}
+              </span>
+              <span><b>Participantes:</b> {{ dadosEstado.pessoas || 0 }}</span>
+            </div>
+            <div
+              v-else
+              style="display: flex; flex-direction: column; gap: 0.5rem"
+            >
+              <span>
+                <b>Selecione um estado</b>
+              </span>
+            </div>
+          </div>
+        </div>
+      </q-card-section>
+    </q-card>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import MapaEstado from "../MapaEstado.vue";
+import { DadosBrasil } from "./DadosBrasil";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+
+const selected = ref(null);
+const showCircleInfo = ref(false);
+const infoBoxActive = ref(false);
+const infoBoxPosX = ref(0);
+const infoBoxPosY = ref(0);
+const infoBoxData = ref("");
+const child = ref(null);
+const items = ref(DadosBrasil);
+const ref_mapa = ref(null);
+
+const props = defineProps({
+  data: {
+    type: Array,
+    required: true,
+  },
+  title: {
+    type: String,
+    required: false,
+    default: "PARTICIPANTES POR ESTADO - BRASIL",
+  },
+  subtitle: {
+    type: String,
+    required: false,
+    default: "Número de participantes do evento por estado brasileiro",
+  },
+});
+
+const mapclick = (event) => {
+  // console.log("mapa-brasil: mapclick");
+  if (event.target.tagName == "svg") {
+    resetSelectionAction();
+    infoBoxActive.value = false;
+  }
+};
+const onStateSelectedEvent = (args) => {
+  // console.log("mapa-brasil: onStateSelectedEvent: ", args);
+  selected.value = args.src.item.uf;
+  resetSelectionAction();
+  args.enable();
+};
+const onStateMouseOverEvent = (args) => {
+  // console.log("mapa-brasil: onStateMouseOverEvent: ", args);
+  setInfoBoxPosition({ x: args.event.pageX, y: args.event.pageY });
+  setInfoBoxData([
+    args.src.item.name,
+    args.src.item.regional,
+    args.src.item.altText,
+  ]);
+  infoBoxActive.value = true;
+};
+const resetSelectionAction = () => {
+  // console.log("mapa-brasil: resetSelectionAction", child.value);
+  for (let index = 0; index < child.value.length; index++) {
+    child.value[index].resetAction();
+  }
+};
+const setInfoBoxPosition = (args) => {
+  infoBoxPosX.value = args.x + 30;
+  infoBoxPosY.value = args.y;
+};
+const setInfoBoxData = (args) => {
+  infoBoxData.value = args.filter(function (a) {
+    return a !== "" ? a : null;
+  });
+};
+const hideInfoBox = (args) => {
+  if (
+    ["DIV", "SVG"].indexOf(args.target.tagName.toString().toUpperCase()) > -1
+  ) {
+    infoBoxActive.value = false;
+  }
+};
+
+onMounted(() => {
+  const dataOrganizada = [...props.data].sort(
+    (a, b) => a?.total_pessoas_por_estado - b?.total_pessoas_por_estado
+  );
+  const maiorNumero =
+    dataOrganizada[dataOrganizada.length - 1]?.total_pessoas_por_estado;
+  const faixa = maiorNumero / 5;
+  const colors = ["#f6a0a1", "#f27e7f", "#ee585a", "#EB3537", "#c31315"];
+  items.value.forEach((item) => {
+    const estado = dataOrganizada.find((e) => e.estado === item.uf);
+    if (estado) {
+      const indexFaixa = Math.ceil(estado?.total_pessoas_por_estado / faixa);
+      item.color = colors[indexFaixa - 1];
+      item.pessoas = estado.total_pessoas_por_estado;
+    } else {
+      item.color = "#494949";
+      item.pessoas = 0;
+    }
+  });
+});
+
+const dadosEstado = computed(() => {
+  const estado = items.value.find((item) => item.uf === selected.value);
+  return estado;
+});
+
+const downloadImage = () => {
+  const svgString = new XMLSerializer().serializeToString(ref_mapa.value);
+  const base64String = btoa(svgString);
+  base64ToJPEG(base64String, props.title);
+};
+</script>
+
+<style lang="scss">
+svg text {
+  fill: var(--default-stroke);
+  font-family: monospace;
+}
+
+.mapa-svg-estados {
+  fill: var(--default-fill);
+  -webkit-transition: 0.8s ease;
+  -moz-transition: 0.8s ease;
+  -ms-transition: 0.8s ease;
+  -o-transition: 0.8s ease;
+  transition: 0.8s ease;
+  stroke-dasharray: 180%;
+  stroke-dashoffset: -120%;
+  stroke-width: 1px;
+  stroke: var(--default-stroke);
+  text {
+    fill: var(--default-stroke);
+    stroke: none !important;
+  }
+}
+
+.mapa-svg-estados:hover {
+  cursor: pointer;
+  fill: #8d1012 !important;
+}
+
+.mapa-svg-estados-active {
+  cursor: pointer;
+  stroke: #ffffff;
+  fill: #ffc712 !important;
+  stroke-dashoffset: 0%;
+  transition: 0.8s ease;
+  -webkit-transition: 0.8s ease;
+  -moz-transition: 0.8s ease;
+  -ms-transition: 0.8s ease;
+  -o-transition: 0.8s ease;
+  text {
+    fill: #ffffff;
+    stroke: none !important;
+  }
+}
+</style>

+ 110 - 0
src/components/charts/maps/MapaEstado.vue

@@ -0,0 +1,110 @@
+<template>
+  <g
+    ref="chart_ref"
+    :class="[classObject]"
+    :data-regional="item.regional"
+    @click="clickAction"
+  >
+    <path :d="item.svgData" />
+    <text
+      :transform="item.textData"
+      class="item text-weight-medium"
+      style="font-size: 10px"
+      fill="white"
+    >
+      {{ item.pessoas || 0 }}
+    </text>
+    <circle
+      v-if="showCircleInfo"
+      :cy="item.circleData.cy"
+      :cx="item.circleData.cx"
+      r="10"
+      :class="[counterClassObject]"
+    />
+  </g>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+
+const props = defineProps({
+  item: {
+    type: Object,
+    default: () => ({
+      altText: "--",
+    }),
+  },
+  showCircleInfo: {
+    type: Boolean,
+    default: true,
+  },
+});
+
+const emit = defineEmits(["stateSelectedEvent", "stateMouseOverEvent"]);
+
+const active = ref(false);
+const counterActive = ref(false);
+const altText = ref(props.item.altText || "-");
+const chart_ref = ref(null);
+
+const classObject = computed(() => {
+  return active.value ? ["mapa-svg-estados-active"] : ["mapa-svg-estados"];
+});
+
+const counterClassObject = computed(() => {
+  return counterActive.value ? ["sphere active"] : ["sphere"];
+});
+
+// const pad = (value) => {
+//   if (!value) return "";
+//   return ("000000000" + value).slice(-2);
+// };
+
+const clickAction = () => {
+  // console.log("mapa-estado: clickAction()");
+  emit("stateSelectedEvent", {
+    src: {
+      item: props.item,
+    },
+    enable,
+  });
+};
+const resetAction = () => {
+  disable();
+};
+const disable = () => {
+  // console.log("mapa-estado: disabled ", props.item.uf);
+  active.value = false;
+  hideCounter();
+};
+
+const enable = () => {
+  // console.log("mapa-estado: enable ");
+  active.value = true;
+  showCounter();
+};
+const setAltText = (val) => {
+  altText.value = val;
+};
+const hideCounter = () => {
+  counterActive.value = false;
+};
+const showCounter = () => {
+  counterActive.value = true;
+};
+
+onMounted(() => {
+  altText.value = props.item.altText;
+});
+
+defineExpose({
+  resetAction,
+  setAltText,
+});
+</script>
+
+<style>
+.item {
+  fill: rgb(240, 221, 221);
+}
+</style>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 14 - 0
src/components/charts/maps/Paraguai/DadosParaguai.js


+ 225 - 0
src/components/charts/maps/Paraguai/MapaParaguai.vue

@@ -0,0 +1,225 @@
+<!-- eslint-disable @intlify/vue-i18n/no-raw-text -->
+<template>
+  <div>
+    <q-card style="height: 100%">
+      <q-card-section class="row justify-between">
+        <div class="column">
+          <span class="text-bold text-description q-mb-sm">
+            {{ props.title }}
+          </span>
+          <span>{{ props.subtitle }}</span>
+        </div>
+        <q-btn
+          icon="mdi-tray-arrow-down"
+          dense
+          flat
+          class="q-my-auto"
+          @click="downloadImage"
+        />
+      </q-card-section>
+
+      <q-separator inset />
+      <q-card-section>
+        <div style="display: flex; flex-direction: column">
+          <div @mouseover="hideInfoBox">
+            <svg
+              ref="ref_mapa"
+              xmlns="http://www.w3.org/2000/svg"
+              class="mapa-svg-estados"
+              viewBox="-100 -60 1250 1250"
+              @click="mapclick"
+            >
+              <g id="mapa-svg-area">
+                <MapaEstado
+                  v-for="item in items"
+                  ref="child"
+                  :key="item.id"
+                  :item="item"
+                  :show-circle-info="showCircleInfo"
+                  :class="item.classObject"
+                  :style="item.color ? `fill: ${item.color}` : ''"
+                  @state-selected-event="onStateSelectedEvent"
+                  @state-mouse-over-event="onStateMouseOverEvent"
+                ></MapaEstado>
+              </g>
+            </svg>
+          </div>
+          <div class="bg-background--2 q-pa-md" style="border-radius: 0.5rem">
+            <div
+              v-if="selected"
+              style="display: flex; flex-direction: column; gap: 0.5rem"
+            >
+              <span> <b> Estado:</b> {{ dadosEstado.name }} </span>
+              <span><b>Participantes:</b> {{ dadosEstado.pessoas || 0 }}</span>
+            </div>
+            <div
+              v-else
+              style="display: flex; flex-direction: column; gap: 0.5rem"
+            >
+              <span>
+                <b>Selecione um estado</b>
+              </span>
+            </div>
+          </div>
+        </div>
+      </q-card-section>
+    </q-card>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import MapaEstado from "../MapaEstado.vue";
+import { DadosParaguai } from "./DadosParaguai";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+
+const selected = ref(null);
+const showCircleInfo = ref(false);
+const infoBoxActive = ref(false);
+const infoBoxPosX = ref(0);
+const infoBoxPosY = ref(0);
+const infoBoxData = ref("");
+const child = ref(null);
+const items = ref(DadosParaguai);
+const ref_mapa = ref(null);
+
+const props = defineProps({
+  data: {
+    type: Array,
+    required: true,
+  },
+  title: {
+    type: String,
+    required: false,
+    default: "PARTICIPANTES POR DEPARTAMENTO - PARAGUAI",
+  },
+  subtitle: {
+    type: String,
+    required: false,
+    default: "Número de participantes do evento por departamento do Paraguai",
+  },
+});
+
+const mapclick = (event) => {
+  // console.log("mapa-brasil: mapclick");
+  if (event.target.tagName == "svg") {
+    resetSelectionAction();
+    infoBoxActive.value = false;
+  }
+};
+const onStateSelectedEvent = (args) => {
+  // console.log("mapa-brasil: onStateSelectedEvent: ", args);
+  selected.value = args.src.item.id;
+  resetSelectionAction();
+  args.enable();
+};
+const onStateMouseOverEvent = (args) => {
+  // console.log("mapa-brasil: onStateMouseOverEvent: ", args);
+  setInfoBoxPosition({ x: args.event.pageX, y: args.event.pageY });
+  setInfoBoxData([
+    args.src.item.name,
+    args.src.item.regional,
+    args.src.item.altText,
+  ]);
+  infoBoxActive.value = true;
+};
+const resetSelectionAction = () => {
+  // console.log("mapa-brasil: resetSelectionAction", child.value);
+  for (let index = 0; index < child.value.length; index++) {
+    child.value[index].resetAction();
+  }
+};
+const setInfoBoxPosition = (args) => {
+  infoBoxPosX.value = args.x + 30;
+  infoBoxPosY.value = args.y;
+};
+const setInfoBoxData = (args) => {
+  infoBoxData.value = args.filter(function (a) {
+    return a !== "" ? a : null;
+  });
+};
+const hideInfoBox = (args) => {
+  if (
+    ["DIV", "SVG"].indexOf(args.target.tagName.toString().toUpperCase()) > -1
+  ) {
+    infoBoxActive.value = false;
+  }
+};
+
+onMounted(() => {
+  const dataOrganizada = [...props.data].sort(
+    (a, b) => a?.total_pessoas_por_estado - b?.total_pessoas_por_estado
+  );
+  const maiorNumero =
+    dataOrganizada[dataOrganizada.length - 1]?.total_pessoas_por_estado;
+  const faixa = maiorNumero / 5;
+  const colors = ["#f6a0a1", "#f27e7f", "#ee585a", "#EB3537", "#c31315"];
+  items.value.forEach((item) => {
+    const estado = dataOrganizada.find((e) => e.estado_id === item.id);
+    if (estado) {
+      const indexFaixa = Math.ceil(estado?.total_pessoas_por_estado / faixa);
+      item.color = colors[indexFaixa - 1];
+      item.pessoas = estado.total_pessoas_por_estado;
+    } else {
+      item.color = "#494949";
+      item.pessoas = 0;
+    }
+  });
+});
+
+const dadosEstado = computed(() => {
+  const estado = items.value.find((item) => item.id === selected.value);
+  return estado;
+});
+
+const downloadImage = () => {
+  const svgString = new XMLSerializer().serializeToString(ref_mapa.value);
+  const base64String = btoa(svgString);
+  base64ToJPEG(base64String, props.title);
+};
+</script>
+
+<style lang="scss">
+svg text {
+  fill: var(--default-stroke);
+  font-family: monospace;
+}
+
+.mapa-svg-estados {
+  fill: var(--default-fill);
+  -webkit-transition: 0.8s ease;
+  -moz-transition: 0.8s ease;
+  -ms-transition: 0.8s ease;
+  -o-transition: 0.8s ease;
+  transition: 0.8s ease;
+  stroke-dasharray: 180%;
+  stroke-dashoffset: -120%;
+  stroke-width: 1px;
+  stroke: var(--default-stroke);
+  text {
+    fill: var(--default-stroke);
+    stroke: none !important;
+  }
+}
+
+.mapa-svg-estados:hover {
+  cursor: pointer;
+  fill: #8d1012 !important;
+}
+
+.mapa-svg-estados-active {
+  cursor: pointer;
+  stroke: #ffffff;
+  fill: #ffc712 !important;
+  stroke-dashoffset: 0%;
+  transition: 0.8s ease;
+  -webkit-transition: 0.8s ease;
+  -moz-transition: 0.8s ease;
+  -ms-transition: 0.8s ease;
+  -o-transition: 0.8s ease;
+  text {
+    fill: #ffffff;
+    stroke: none !important;
+  }
+}
+</style>

+ 23 - 35
src/components/charts/mini/MiniBarChart.vue

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

+ 28 - 35
src/components/charts/mini/MiniLineChart.vue

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

+ 267 - 0
src/components/charts/normal/BarChart.vue

@@ -0,0 +1,267 @@
+<template>
+  <div v-bind="$attrs" class="chart-wrapper full-width full-height">
+    <q-resize-observer @resize="onResize" />
+    <div v-if="hasData" class="chart-container">
+      <Bar
+        ref="chart_ref"
+        :options="chartBarOptions"
+        :data="chartBarData"
+        :plugins="[ChartDataLabels]"
+      />
+    </div>
+    <div v-else class="no-data-container">
+      <span :class="textColor">{{ $t("http.errors.no_records_found") }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { Bar } from "vue-chartjs";
+import {
+  Chart as ChartJS,
+  Title,
+  Tooltip,
+  Legend,
+  BarElement,
+  CategoryScale,
+  LinearScale,
+} from "chart.js";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+import { useQuasar, colors, getCssVar } from "quasar";
+
+ChartJS.register(
+  Title,
+  Tooltip,
+  Legend,
+  BarElement,
+  CategoryScale,
+  LinearScale,
+);
+
+const $q = useQuasar();
+const chart_ref = ref(null);
+const { lighten } = colors;
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({
+      chart_data: [],
+      current_total: 0,
+    }),
+  },
+  dataSetLabel: {
+    type: String,
+    default: "Quantidade",
+  },
+  labelX: {
+    type: String,
+    default: "Categorias",
+  },
+  labelY: {
+    type: String,
+    default: "Valores",
+  },
+  showLegend: {
+    type: Boolean,
+    default: false,
+  },
+  title: {
+    type: String,
+    default: "Título",
+  },
+  backgroundColors: {
+    type: Array,
+    default: null,
+  },
+});
+
+const onResize = () => {
+  if (chart_ref.value?.chart) {
+    setTimeout(() => {
+      chart_ref.value.chart.resize();
+    }, 50);
+  }
+};
+
+const textColor = computed(() => {
+  return $q.dark.isActive ? "text-white" : "text-black";
+});
+
+const labelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const gridColor = computed(() => {
+  return $q.dark.isActive ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)";
+});
+
+const dataLabelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const hasData = computed(() => {
+  return props.data?.chart_data && props.data.chart_data.length > 0;
+});
+
+const chartLabels = computed(() => {
+  return props.data?.chart_data?.map((item) => item.label) || [];
+});
+
+const chartValues = computed(() => {
+  return props.data?.chart_data?.map((item) => item.value) || [];
+});
+
+const chartThemeColors = computed(() => {
+  if (props.backgroundColors) {
+    return props.backgroundColors;
+  }
+
+  const primaryColor = getCssVar("primary");
+  if (!primaryColor) return [];
+
+  const numColors = chartValues.value.length;
+  const step = numColors > 0 ? 50 / numColors : 0;
+  return Array.from({ length: numColors }, (_, i) =>
+    lighten(primaryColor, i * step),
+  );
+});
+
+const chartBarData = computed(() => ({
+  labels: chartLabels.value,
+  datasets: [
+    {
+      label: props.dataSetLabel,
+      data: chartValues.value,
+      backgroundColor: chartThemeColors.value,
+      borderColor: chartThemeColors.value,
+      borderWidth: 1,
+    },
+  ],
+}));
+
+const chartBarOptions = computed(() => ({
+  responsive: true,
+  maintainAspectRatio: false,
+  plugins: {
+    legend: {
+      display: props.showLegend,
+      position: "top",
+      labels: {
+        color: labelColor.value,
+        font: {
+          size: 14,
+        },
+      },
+    },
+    datalabels: {
+      color: dataLabelColor.value,
+      anchor: "end",
+      align: "top",
+      offset: 4,
+      font: {
+        size: 12,
+        weight: "bold",
+      },
+      formatter: (value) => {
+        return value > 0 ? value : "";
+      },
+    },
+    tooltip: {
+      backgroundColor: $q.dark.isActive
+        ? "rgba(0, 0, 0, 0.8)"
+        : "rgba(255, 255, 255, 0.9)",
+      titleColor: labelColor.value,
+      bodyColor: labelColor.value,
+      borderColor: labelColor.value,
+      borderWidth: 1,
+    },
+  },
+  scales: {
+    x: {
+      display: true,
+      title: {
+        display: !!props.labelX,
+        text: props.labelX,
+        color: labelColor.value,
+        font: {
+          size: 14,
+        },
+      },
+      grid: {
+        color: gridColor.value,
+        tickColor: gridColor.value,
+      },
+      ticks: {
+        color: labelColor.value,
+        font: {
+          size: 12,
+        },
+      },
+    },
+    y: {
+      display: true,
+      title: {
+        display: !!props.labelY,
+        text: props.labelY,
+        color: labelColor.value,
+        font: {
+          size: 14,
+        },
+      },
+      suggestedMin: 0,
+      grid: {
+        color: gridColor.value,
+        tickColor: gridColor.value,
+      },
+      ticks: {
+        color: labelColor.value,
+        font: {
+          size: 12,
+        },
+        stepSize: 1,
+      },
+    },
+  },
+}));
+
+const downloadImage = () => {
+  const image = chart_ref.value.chart?.toBase64Image("image/jpeg", 1);
+  base64ToJPEG(image, props.title || "bar-chart");
+};
+
+defineExpose({
+  downloadImage,
+  chart_ref,
+});
+</script>
+
+<style scoped>
+.chart-wrapper {
+  position: relative;
+}
+
+.chart-container {
+  position: absolute;
+  top: 10px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.no-data-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1.5rem;
+}
+</style>

+ 204 - 0
src/components/charts/normal/DoughnutChart.vue

@@ -0,0 +1,204 @@
+<template>
+  <div v-bind="$attrs" class="chart-wrapper full-width full-height">
+    <q-resize-observer @resize="onResize" />
+    <div v-if="hasData" class="chart-container">
+      <Doughnut
+        ref="chart_ref"
+        :options="chartPieOptions"
+        :data="chartPieData"
+        :plugins="[ChartDataLabels]"
+      />
+    </div>
+    <div v-else class="no-data-container">
+      <span :class="textColor">{{ $t("http.errors.no_records_found") }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { Doughnut } from "vue-chartjs";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+import { useQuasar, colors, getCssVar } from "quasar";
+
+ChartJS.register(ArcElement, Tooltip, Legend);
+
+const $q = useQuasar();
+const { lighten } = colors;
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({
+      chart_data: [],
+    }),
+  },
+  backgroundColors: {
+    type: Array,
+    default: null,
+  },
+  title: {
+    type: String,
+    default: "doughnut-chart",
+  },
+  dataSetLabel: {
+    type: String,
+    default: "Dados",
+  },
+});
+
+const chart_ref = ref(null);
+
+const onResize = () => {
+  if (chart_ref.value?.chart) {
+    setTimeout(() => {
+      chart_ref.value.chart.resize();
+    }, 50);
+  }
+};
+
+const textColor = computed(() => {
+  return $q.dark.isActive ? "text-white" : "text-black";
+});
+
+const labelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const dataLabelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const hasData = computed(() => {
+  return props.data?.chart_data && props.data.chart_data.length > 0;
+});
+
+const chartLabels = computed(() => {
+  return props.data?.chart_data?.map((item) => item.label) || [];
+});
+
+const chartValues = computed(() => {
+  return props.data?.chart_data?.map((item) => item.value) || [];
+});
+
+const chartPercentages = computed(() => {
+  const total = props.data?.current_total || 0;
+  if (total === 0) return [];
+
+  return (
+    props.data?.chart_data?.map((item) =>
+      Math.round((item.value / total) * 100),
+    ) || []
+  );
+});
+
+const chartThemeColors = computed(() => {
+  if (props.backgroundColors) {
+    return props.backgroundColors;
+  }
+
+  const primaryColor = getCssVar("primary");
+  if (!primaryColor) return [];
+
+  const numColors = chartValues.value.length;
+  const step = numColors > 0 ? 50 / numColors : 0;
+  return Array.from({ length: numColors }, (_, i) =>
+    lighten(primaryColor, i * step),
+  );
+});
+
+const chartPieData = computed(() => ({
+  labels: chartLabels.value,
+  datasets: [
+    {
+      label: props.dataSetLabel,
+      data: chartPercentages.value,
+      backgroundColor: chartThemeColors.value,
+    },
+  ],
+}));
+
+const chartPieOptions = computed(() => ({
+  responsive: true,
+  maintainAspectRatio: false,
+  plugins: {
+    legend: {
+      position: "bottom",
+      labels: {
+        color: labelColor.value,
+        font: {
+          size: 12,
+        },
+        padding: 20,
+      },
+    },
+    datalabels: {
+      color: dataLabelColor.value,
+      font: {
+        size: 12,
+        weight: "bold",
+      },
+      formatter: (value) => {
+        return value > 0 ? value + "%" : "";
+      },
+    },
+    tooltip: {
+      backgroundColor: $q.dark.isActive
+        ? "rgba(0, 0, 0, 0.8)"
+        : "rgba(255, 255, 255, 0.9)",
+      titleColor: labelColor.value,
+      bodyColor: labelColor.value,
+      borderColor: labelColor.value,
+      borderWidth: 1,
+      callbacks: {
+        label: (context) => {
+          const label = context.label || "";
+          const percentage = context.parsed;
+          const actualValue = chartValues.value[context.dataIndex];
+          return `${label}: ${actualValue} (${percentage}%)`;
+        },
+      },
+    },
+  },
+}));
+
+const downloadImage = () => {
+  const image = chart_ref.value.chart?.toBase64Image("image/jpeg", 1);
+  base64ToJPEG(image, props.title || "pie-chart");
+};
+
+defineExpose({
+  downloadImage,
+  chart_ref,
+});
+</script>
+
+<style scoped>
+.chart-wrapper {
+  position: relative;
+}
+
+.chart-container {
+  position: absolute;
+  top: 10px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.no-data-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1.5rem;
+}
+</style>

+ 265 - 0
src/components/charts/normal/LineChart.vue

@@ -0,0 +1,265 @@
+<template>
+  <div v-bind="$attrs" class="chart-wrapper full-width full-height">
+    <q-resize-observer @resize="onResize" />
+    <div v-if="hasData" class="chart-container">
+      <Line
+        ref="chart_ref"
+        :options="chartLineOptions"
+        :data="chartLineData"
+        :plugins="[ChartDataLabels]"
+      />
+    </div>
+    <div v-else class="no-data-container">
+      <span :class="textColor">{{ $t("http.errors.no_records_found") }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { Line } from "vue-chartjs";
+import {
+  Chart as ChartJS,
+  Title,
+  Tooltip,
+  Legend,
+  LineElement,
+  PointElement,
+  CategoryScale,
+  LinearScale,
+} from "chart.js";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+import { useQuasar, getCssVar, colors } from "quasar";
+
+ChartJS.register(
+  Title,
+  Tooltip,
+  Legend,
+  LineElement,
+  PointElement,
+  CategoryScale,
+  LinearScale,
+);
+
+const $q = useQuasar();
+const { lighten } = colors;
+const chart_ref = ref(null);
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({
+      chart_data: [],
+    }),
+  },
+  title: {
+    type: String,
+    default: "",
+  },
+  dataSetLabel: {
+    type: String,
+    default: "Valores",
+  },
+  labelX: {
+    type: String,
+    default: "Categorias",
+  },
+  labelY: {
+    type: String,
+    default: "Valores",
+  },
+  backgroundColors: {
+    type: Array,
+    default: null,
+  },
+});
+
+const onResize = () => {
+  if (chart_ref.value?.chart) {
+    setTimeout(() => {
+      chart_ref.value.chart.resize();
+    }, 50);
+  }
+};
+
+const textColor = computed(() => {
+  return $q.dark.isActive ? "text-white" : "text-black";
+});
+
+const labelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const gridColor = computed(() => {
+  return $q.dark.isActive ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.1)";
+});
+
+const dataLabelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const hasData = computed(() => {
+  return props.data?.chart_data && props.data.chart_data.length > 0;
+});
+
+const chartLabels = computed(() => {
+  return props.data?.chart_data?.map((item) => item.label) || [];
+});
+
+const chartValues = computed(() => {
+  return props.data?.chart_data?.map((item) => item.value) || [];
+});
+
+const chartThemeColors = computed(() => {
+  if (props.backgroundColors) {
+    return props.backgroundColors;
+  }
+
+  const primaryColor = getCssVar("primary");
+  if (!primaryColor) return [];
+
+  const numColors = chartValues.value.length;
+  const step = numColors > 0 ? 50 / numColors : 0;
+  return Array.from({ length: numColors }, (_, i) =>
+    lighten(primaryColor, i * step),
+  );
+});
+
+const chartLineData = computed(() => ({
+  labels: chartLabels.value,
+  datasets: [
+    {
+      label: props.dataSetLabel,
+      data: chartValues.value,
+      borderColor: chartThemeColors.value[0],
+      backgroundColor: chartThemeColors.value,
+      fill: false,
+      cubicInterpolationMode: "monotone",
+      tension: 0.4,
+    },
+  ],
+}));
+
+const chartLineOptions = computed(() => ({
+  responsive: true,
+  maintainAspectRatio: false,
+  plugins: {
+    legend: {
+      display: false,
+    },
+    title: {
+      display: !!props.title,
+      text: props.title,
+      color: labelColor.value,
+      font: {
+        size: 16,
+      },
+    },
+    datalabels: {
+      color: dataLabelColor.value,
+      anchor: "end",
+      align: "top",
+      offset: 4,
+      font: {
+        size: 12,
+        weight: "bold",
+      },
+      formatter: (value) => {
+        return value > 0 ? value : "";
+      },
+    },
+    tooltip: {
+      backgroundColor: $q.dark.isActive
+        ? "rgba(0, 0, 0, 0.8)"
+        : "rgba(255, 255, 255, 0.9)",
+      titleColor: labelColor.value,
+      bodyColor: labelColor.value,
+      borderColor: labelColor.value,
+      borderWidth: 1,
+    },
+  },
+  interaction: {
+    intersect: false,
+  },
+  scales: {
+    x: {
+      display: true,
+      title: {
+        display: !!props.labelX,
+        text: props.labelX,
+        color: labelColor.value,
+        font: {
+          size: 14,
+        },
+      },
+      grid: {
+        color: gridColor.value,
+        tickColor: gridColor.value,
+      },
+      ticks: {
+        color: labelColor.value,
+      },
+    },
+    y: {
+      display: true,
+      title: {
+        display: !!props.labelY,
+        text: props.labelY,
+        color: labelColor.value,
+        font: {
+          size: 14,
+        },
+      },
+      suggestedMin: 0,
+      grid: {
+        color: gridColor.value,
+        tickColor: gridColor.value,
+      },
+      ticks: {
+        color: labelColor.value,
+        stepSize: 1,
+      },
+    },
+  },
+}));
+
+const downloadImage = () => {
+  if (!chart_ref.value?.chart) return;
+  const image = chart_ref.value.chart.toBase64Image("image/jpeg", 1);
+  base64ToJPEG(image, props.title || "line-chart");
+};
+
+defineExpose({
+  downloadImage,
+  chart_ref,
+});
+</script>
+
+<style scoped>
+.chart-wrapper {
+  position: relative;
+}
+
+.chart-container {
+  position: absolute;
+  top: 10px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.no-data-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1.5rem;
+}
+</style>

+ 205 - 0
src/components/charts/normal/PieChart.vue

@@ -0,0 +1,205 @@
+<template>
+  <div v-bind="$attrs" class="chart-wrapper full-width full-height">
+    <q-resize-observer @resize="onResize" />
+    <div v-if="hasData" class="chart-container">
+      <Pie
+        ref="chart_ref"
+        :options="chartPieOptions"
+        :data="chartPieData"
+        :plugins="[ChartDataLabels]"
+      />
+    </div>
+    <div v-else class="no-data-container">
+      <span :class="textColor">{{ $t("http.errors.no_records_found") }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { Pie } from "vue-chartjs";
+import ChartDataLabels from "chartjs-plugin-datalabels";
+import { base64ToJPEG } from "src/helpers/convertBase64Image";
+import { useQuasar, getCssVar, colors } from "quasar";
+
+ChartJS.register(ArcElement, Tooltip, Legend);
+
+const $q = useQuasar();
+const { lighten } = colors;
+
+const props = defineProps({
+  data: {
+    type: Object,
+    default: () => ({
+      chart_data: [],
+      current_total: 0,
+    }),
+  },
+  dataSetLabel: {
+    type: String,
+    default: "Dados",
+  },
+  title: {
+    type: String,
+    default: "Título",
+  },
+  backgroundColors: {
+    type: Array,
+    default: null,
+  },
+});
+
+const chart_ref = ref(null);
+
+const onResize = () => {
+  if (chart_ref.value?.chart) {
+    setTimeout(() => {
+      chart_ref.value.chart.resize();
+    }, 50);
+  }
+};
+
+const textColor = computed(() => {
+  return $q.dark.isActive ? "text-white" : "text-black";
+});
+
+const labelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const dataLabelColor = computed(() => {
+  return $q.dark.isActive ? "#ffffff" : "#000000";
+});
+
+const hasData = computed(() => {
+  return props.data?.chart_data && props.data.chart_data.length > 0;
+});
+
+const chartLabels = computed(() => {
+  return props.data?.chart_data?.map((item) => item.label) || [];
+});
+
+const chartValues = computed(() => {
+  return props.data?.chart_data?.map((item) => item.value) || [];
+});
+
+const chartPercentages = computed(() => {
+  const total = props.data?.current_total || 0;
+  if (total === 0) return [];
+
+  return (
+    props.data?.chart_data?.map((item) =>
+      Math.round((item.value / total) * 100),
+    ) || []
+  );
+});
+
+const chartThemeColors = computed(() => {
+  if (props.backgroundColors) {
+    return props.backgroundColors;
+  }
+
+  const primaryColor = getCssVar("primary");
+  if (!primaryColor) return [];
+
+  const numColors = chartValues.value.length;
+  const step = numColors > 0 ? 50 / numColors : 0;
+  return Array.from({ length: numColors }, (_, i) =>
+    lighten(primaryColor, i * step),
+  );
+});
+
+const chartPieData = computed(() => ({
+  labels: chartLabels.value,
+  datasets: [
+    {
+      label: props.dataSetLabel,
+      data: chartPercentages.value,
+      backgroundColor: chartThemeColors.value,
+    },
+  ],
+}));
+
+const chartPieOptions = computed(() => ({
+  responsive: true,
+  maintainAspectRatio: false,
+  plugins: {
+    legend: {
+      position: "bottom",
+      labels: {
+        color: labelColor.value,
+        font: {
+          size: 12,
+        },
+        padding: 20,
+      },
+    },
+    datalabels: {
+      color: dataLabelColor.value,
+      font: {
+        size: 12,
+        weight: "bold",
+      },
+      formatter: (value) => {
+        return value > 0 ? value + "%" : "";
+      },
+    },
+    tooltip: {
+      backgroundColor: $q.dark.isActive
+        ? "rgba(0, 0, 0, 0.8)"
+        : "rgba(255, 255, 255, 0.9)",
+      titleColor: labelColor.value,
+      bodyColor: labelColor.value,
+      borderColor: labelColor.value,
+      borderWidth: 1,
+      callbacks: {
+        label: (context) => {
+          const label = context.label || "";
+          const percentage = context.parsed;
+          const actualValue = chartValues.value[context.dataIndex];
+          return `${label}: ${actualValue} (${percentage}%)`;
+        },
+      },
+    },
+  },
+}));
+
+const downloadImage = () => {
+  const image = chart_ref.value.chart?.toBase64Image("image/jpeg", 1);
+  base64ToJPEG(image, props.title || "pie-chart");
+};
+
+defineExpose({
+  downloadImage,
+  chart_ref,
+});
+</script>
+
+<style scoped>
+.chart-wrapper {
+  position: relative;
+}
+
+.chart-container {
+  position: absolute;
+  top: 10px;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.no-data-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 1.5rem;
+}
+</style>

+ 3 - 3
src/components/defaults/DefaultCurrencyInput.vue

@@ -3,12 +3,12 @@
     ref="inputRef"
     v-model="formattedValue"
     outlined
-    :error-message="errorMessage"
+    :error-message
     :error="!!errorMessage"
-    :disable="disable"
     :class="disable ? 'no-pointer-events' : ''"
     :label="newLabel"
-    :readonly="readonly"
+    :disable
+    :readonly
   >
   </q-input>
 </template>

+ 77 - 30
src/components/defaults/DefaultTable.vue

@@ -2,16 +2,16 @@
   <q-table
     v-model:fullscreen="fullscreen"
     flat
+    row-key="id"
     :pagination="{ rowsPerPage }"
     :pagination-label="getPaginationLabel"
-    row-key="id"
-    :rows="rows"
     :rows-per-page-label="$t('common.ui.table.rows_per_page')"
-    :columns="columns"
-    :visible-columns="visibleColumns"
-    :filter="filter"
     :grid="$q.screen.lt.sm"
-    :loading="loading"
+    :visible-columns
+    :filter
+    :columns
+    :rows
+    :loading
     class="softpar-table q-pa-sm"
     @row-click="onRowClick"
   >
@@ -48,6 +48,8 @@
           options-selected-class="text-bold"
         />
 
+        <slot name="top" :rows="rows" />
+
         <q-space />
 
         <q-btn
@@ -63,8 +65,9 @@
     </template>
 
     <template #body-cell-actions="{ row }">
-      <q-td v-if="deleteFunction">
-        <q-item-section>
+      <q-td auto-width>
+        <slot name="body-cell-actions" :row="row" />
+        <q-item-section v-if="deleteFunction">
           <q-btn
             color="negative"
             flat
@@ -78,6 +81,45 @@
       </q-td>
     </template>
 
+    <template #item="{ cols, row, index }">
+      <div class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3">
+        <q-card
+          bordered
+          flat
+          class="q-pa-sm"
+          @click="onRowClick($event, row, index)"
+        >
+          <q-list dense>
+            <q-item
+              v-for="col in cols.filter((col) => col.name !== 'desc')"
+              :key="col.name"
+            >
+              <template v-if="col.name !== 'actions'">
+                <q-item-section>
+                  <q-item-label caption>{{ col.label }}</q-item-label>
+                  <q-item-label>{{ col.value }}</q-item-label>
+                </q-item-section>
+              </template>
+              <template v-else>
+                <slot name="body-cell-actions" :row="row" />
+                <q-item-section v-if="deleteFunction">
+                  <q-btn
+                    color="negative"
+                    flat
+                    dense
+                    icon="mdi-delete"
+                    style="width: 45px"
+                    class="q-ml-auto q-mr-sm"
+                    @click.prevent.stop="onDelete(col.id)"
+                  />
+                </q-item-section>
+              </template>
+            </q-item>
+          </q-list>
+        </q-card>
+      </div>
+    </template>
+
     <template #loading>
       <q-inner-loading showing color="primary" />
     </template>
@@ -98,7 +140,9 @@
 
 <script setup>
 import { ref, onMounted, toRaw, watch } from "vue";
+import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
+import { useQuasar } from "quasar";
 
 const emit = defineEmits(["onRowClick", "onAddItem", "noRows"]);
 
@@ -171,28 +215,18 @@ const {
 });
 
 const router = useRouter();
+const { t } = useI18n();
+const $q = useQuasar();
+
 const rows = ref([]);
 const filter = ref("");
 const loading = ref(true);
 const fullscreen = ref(false);
-const showInativos = ref(false);
-const inativos = ref([]);
 
 const getPaginationLabel = (from, to, last) => {
   return `${from}-${to} de ${last}`;
 };
 
-watch(showInativos, () => {
-  if (showInativos.value) {
-    rows.value = rows.value.concat(inativos.value);
-  } else {
-    inativos.value = rows.value.filter(
-      (row) => row.status === false || row.ativo === false,
-    );
-    rows.value = rows.value.filter((row) => row.ativo);
-  }
-});
-
 watch(
   () => apiCall,
   async () => {
@@ -235,15 +269,28 @@ const onAddItem = () => {
 
 const onDelete = async (id) => {
   if (deleteFunction) {
-    loading.value = true;
-    try {
-      await deleteFunction(id);
-      await onRequest();
-    } catch (error) {
-      console.error(error);
-    } finally {
-      loading.value = false;
-    }
+    $q.dialog({
+      title: t("common.ui.messages.confirm_action"),
+      message: t("common.ui.messages.are_you_sure_delete"),
+      ok: {
+        color: "negative",
+        label: t("common.actions.delete"),
+      },
+      cancel: {
+        color: "primary",
+        label: t("common.actions.cancel"),
+      },
+    }).onOk(async () => {
+      loading.value = true;
+      try {
+        await deleteFunction(id);
+        await onRequest();
+      } catch (error) {
+        console.error(error);
+      } finally {
+        loading.value = false;
+      }
+    });
   }
 };
 

+ 4 - 2
src/components/layout/DefaultHeaderPage.vue

@@ -8,8 +8,10 @@
         :to="{ name: breadcrumb?.name }"
       />
     </q-breadcrumbs>
-
-    <span class="text-h5">{{ $t($route.meta?.title) }}</span>
+    <div class="flex items-center justify-between">
+      <span class="text-h5">{{ $t($route.meta?.title) }}</span>
+      <slot name="after" />
+    </div>
     <q-separator class="q-my-sm" />
   </div>
 </template>

+ 70 - 64
src/components/layout/LeftMenuLayout.vue

@@ -34,72 +34,13 @@
             :offset="[10, 10]"
             >{{
               miniState
-                ? $t('ui.navigation.expand_menu')
-                : $t('ui.navigation.collapse_menu')
+                ? $t("ui.navigation.expand_menu")
+                : $t("ui.navigation.collapse_menu")
             }}</q-tooltip
           >
         </q-btn>
       </div>
 
-      <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
-        <q-item v-ripple clickable>
-          <div class="flex">
-            <q-item-section avatar>
-              <template #default>
-                <img
-                  :src="someAvatar()"
-                  alt="avatar"
-                  style="width: 20px; height: 20px; border-radius: 50%"
-                />
-              </template>
-            </q-item-section>
-            <q-item-section>{{ user_store.user.name }}</q-item-section>
-          </div>
-          <q-tooltip
-            v-if="miniState"
-            anchor="center right"
-            self="center left"
-            :offset="[10, 10]"
-            >{{ user_store.user.name }}</q-tooltip
-          >
-          <q-menu anchor="center right" self="top start">
-            <q-list class="column no-wrap overflow-hidden">
-              <q-item
-                v-ripple
-                v-close-popup
-                clickable
-                :to="{ name: 'ProfilePage' }"
-                exact
-                exact-active-class="menu-selected"
-              >
-                <div class="flex">
-                  <q-item-section avatar>
-                    <q-icon
-                      name="account_circle"
-                      color="primary"
-                      style="font-size: 18px"
-                    />
-                  </q-item-section>
-                  <q-item-section>{{ $t("user.profile.singular") }}</q-item-section>
-                </div>
-              </q-item>
-              <q-item v-ripple clickable @click="logoutFn">
-                <div class="flex">
-                  <q-item-section avatar>
-                    <q-icon
-                      name="logout"
-                      color="negative"
-                      style="font-size: 18px"
-                    />
-                  </q-item-section>
-                  <q-item-section>{{ $t('auth.logout') }}</q-item-section>
-                </div>
-              </q-item>
-            </q-list>
-          </q-menu>
-        </q-item>
-      </q-list>
-
       <q-list class="column no-wrap">
         <template v-for="item in navigationItems" :key="item.name">
           <template v-if="item.permission">
@@ -219,7 +160,72 @@
           </template>
         </template>
       </q-list>
+      <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
+      </q-list>
       <q-list class="q-mt-auto">
+        <q-item v-ripple clickable>
+          <div class="flex">
+            <q-item-section avatar>
+              <template #default>
+                <!-- <img
+                  :src="someAvatar()"
+                  alt="avatar"
+                  style="width: 20px; height: 20px; border-radius: 50%"
+                /> -->
+                <q-icon
+                  name="mdi-account"
+                  color="primary"
+                  style="font-size: 20px"
+                />
+              </template>
+            </q-item-section>
+            <q-item-section>{{ user_store.user.name }}</q-item-section>
+          </div>
+          <q-tooltip
+            v-if="miniState"
+            anchor="center right"
+            self="center left"
+            :offset="[10, 10]"
+            >{{ user_store.user.name }}</q-tooltip
+          >
+          <q-menu anchor="center right" self="top start">
+            <q-list class="column no-wrap overflow-hidden">
+              <q-item
+                v-ripple
+                v-close-popup
+                clickable
+                :to="{ name: 'ProfilePage' }"
+                exact
+                exact-active-class="menu-selected"
+              >
+                <div class="flex">
+                  <q-item-section avatar>
+                    <q-icon
+                      name="account_circle"
+                      color="primary"
+                      style="font-size: 18px"
+                    />
+                  </q-item-section>
+                  <q-item-section>{{
+                    $t("user.profile.singular")
+                  }}</q-item-section>
+                </div>
+              </q-item>
+              <q-item v-ripple clickable @click="logoutFn">
+                <div class="flex">
+                  <q-item-section avatar>
+                    <q-icon
+                      name="logout"
+                      color="negative"
+                      style="font-size: 18px"
+                    />
+                  </q-item-section>
+                  <q-item-section>{{ $t("auth.logout") }}</q-item-section>
+                </div>
+              </q-item>
+            </q-list>
+          </q-menu>
+        </q-item>
         <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
           <div class="flex full-width justify-center">
             <q-img
@@ -271,9 +277,9 @@ const childrenAreActive = (children) => {
   });
 };
 
-const someAvatar = () => {
-  return "https://cdn.quasar.dev/img/avatar4.jpg";
-};
+// const someAvatar = () => {
+//   return "https://cdn.quasar.dev/img/avatar4.jpg";
+// };
 
 const isExpasionItemExpanded = ref(false);
 

+ 1 - 9
src/components/regions/CountrySelect.vue

@@ -30,7 +30,7 @@ import { getCountries } from "src/api/country";
 import { ref, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
 
-const { label, rules, initialId } = defineProps({
+const { label, rules } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("ui.navigation.country"),
@@ -39,11 +39,6 @@ const { label, rules, initialId } = defineProps({
     type: Array,
     default: () => [],
   },
-  initialId: {
-    type: Number,
-    required: false,
-    default: null,
-  },
 });
 
 const selectedCountry = defineModel();
@@ -90,9 +85,6 @@ onMounted(async () => {
       label: country.name,
       value: country.id,
     }));
-    if (initialId) {
-      selectCountryById(initialId);
-    }
   } catch (e) {
     console.log(e);
   } finally {

+ 1 - 1
src/composables/useAuth.js

@@ -1,4 +1,4 @@
-import { api } from "src/boot/axios";
+import api from "src/api";
 import { Cookies } from "quasar";
 import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";

+ 87 - 11
src/composables/useFormUpdateTracker.js

@@ -1,26 +1,102 @@
-import { reactive, computed } from "vue";
+import { reactive, computed, watch, toRaw, isReactive } from "vue";
 
-export const useFormUpdateTracker = (initalFormValue) => {
-  const form = reactive({ ...initalFormValue });
-  const originalForm = { ...initalFormValue };
+export const useFormUpdateTracker = (initialFormValue) => {
+  const form = reactive(deepClone(initialFormValue));
+  let originalForm = deepClone(initialFormValue);
+  const updatedFields = reactive({});
 
   const getUpdatedFields = computed(() => {
-    const updatedFields = {};
-    for (const key in form) {
-      if (form[key] !== originalForm[key]) {
-        updatedFields[key] = form[key];
-      }
-    }
     return updatedFields;
   });
 
   const hasUpdatedFields = computed(() => {
-    return Object.keys(getUpdatedFields.value).length > 0;
+    return Object.keys(updatedFields).length > 0;
   });
 
+  const handleNestedObjects = (obj, orig, pathArr = []) => {
+    Object.keys(obj).forEach((key) => {
+      const value = obj[key];
+      const origValue = orig ? orig[key] : undefined;
+      const currentPath = pathArr.concat(key);
+      const pathStr = currentPath.join(".");
+
+      if (Array.isArray(value)) {
+        if (!isEqual(value, origValue)) {
+          updatedFields[pathStr] = deepClone(value);
+        } else {
+          delete updatedFields[pathStr];
+        }
+      } else if (value && typeof value === "object" && !Array.isArray(value)) {
+        handleNestedObjects(value, origValue, currentPath);
+      } else {
+        if (!isEqual(value, origValue)) {
+          updatedFields[pathStr] = value;
+        } else {
+          delete updatedFields[pathStr];
+        }
+      }
+    });
+  };
+
+  watch(
+    form,
+    (newValue) => {
+      handleNestedObjects(newValue, originalForm);
+    },
+    { deep: true },
+  );
+
+  const resetUpdateForm = () => {
+    Object.keys(form).forEach((key) => {
+      form[key] = deepClone(originalForm[key]);
+    });
+    Object.keys(updatedFields).forEach((key) => {
+      delete updatedFields[key];
+    });
+  };
+
+  const setUpdateFormAsOriginal = () => {
+    originalForm = deepClone(form);
+    Object.keys(updatedFields).forEach((key) => {
+      delete updatedFields[key];
+    });
+  };
+
   return {
     form,
     getUpdatedFields,
     hasUpdatedFields,
+    resetUpdateForm,
+    setUpdateFormAsOriginal,
   };
 };
+
+function deepClone(obj) {
+  if (obj && isReactive(obj)) {
+    obj = toRaw(obj);
+  }
+  if (typeof structuredClone === "function") {
+    try {
+      return structuredClone(obj);
+    } catch (e) {
+      console.warn("structuredClone not supported, using JSON methods instead");
+    }
+  }
+  return JSON.parse(JSON.stringify(obj));
+}
+
+function isEqual(a, b) {
+  if (a === b) return true;
+  if (typeof a !== typeof b) return false;
+  if (Array.isArray(a) && Array.isArray(b)) {
+    if (a.length !== b.length) return false;
+    return a.every((item, i) => isEqual(item, b[i]));
+  }
+  if (typeof a === "object" && a && b) {
+    const aKeys = Object.keys(a);
+    const bKeys = Object.keys(b);
+    if (aKeys.length !== bKeys.length) return false;
+    return aKeys.every((key) => isEqual(a[key], b[key]));
+  }
+  return false;
+}

+ 70 - 8
src/composables/useInputRules.js

@@ -3,11 +3,8 @@ import { useI18n } from "vue-i18n";
 export const useInputRules = () => {
   const { t } = useI18n();
 
-  // Regex patterns
   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 passwordPattern = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/;
   const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
 
@@ -21,9 +18,12 @@ export const useInputRules = () => {
     max: (max) => (value) =>
       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;
       const emails = value.split(";").map((email) => email.trim());
@@ -32,18 +32,80 @@ export const useInputRules = () => {
         t("validation.rules.email")
       );
     },
-    cpf: (value) => !value || cpfPattern.test(value) || t("validation.rules.cpf"),
-    cnpj: (value) => !value || cnpjPattern.test(value) || t("validation.rules.cnpj"),
+    cpf: (value) => !value || isValidCPF(value) || t("validation.rules.cpf"),
+    cnpj: (value) => !value || isValidCNPJ(value) || t("validation.rules.cnpj"),
     samePassword: (otherValue) => (value) =>
       value === otherValue || t("validation.rules.same_password"),
     password: (value) =>
       !value || passwordPattern.test(value) || t("validation.rules.password"),
     cep: (value) => {
+      if (!value) return true;
       return cepPattern.test(value) || t("validation.rules.cep");
     },
+    notSameDocument: (allDocuments) => (value) => {
+      if (!value) return true;
+      let found = 0;
+      for (const doc of allDocuments) {
+        if (doc == value) {
+          found++;
+        }
+        if (found > 1) {
+          return t("validation.rules.not_same_document");
+        }
+      }
+      return true;
+    },
   };
 
   return {
     inputRules,
   };
 };
+
+function isValidCPF(cpf) {
+  console.log("isValidCPF", cpf);
+  if (!cpf) return false;
+  cpf = cpf.replace(/[^\d]+/g, "");
+  if (cpf.length !== 11) return false;
+  if (/^(\d)\1+$/.test(cpf)) return false;
+  let sum = 0;
+  for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i);
+  let rev = 11 - (sum % 11);
+  if (rev === 10 || rev === 11) rev = 0;
+  if (rev !== parseInt(cpf.charAt(9))) return false;
+  sum = 0;
+  for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i);
+  rev = 11 - (sum % 11);
+  if (rev === 10 || rev === 11) rev = 0;
+  if (rev !== parseInt(cpf.charAt(10))) return false;
+  return true;
+}
+
+function isValidCNPJ(cnpj) {
+  if (!cnpj) return false;
+  cnpj = cnpj.replace(/[^\d]+/g, "");
+  if (cnpj.length !== 14) return false;
+  if (/^(\d)\1+$/.test(cnpj)) return false;
+  let length = cnpj.length - 2;
+  let numbers = cnpj.substring(0, length);
+  let digits = cnpj.substring(length);
+  let sum = 0;
+  let pos = length - 7;
+  for (let i = length; i >= 1; i--) {
+    sum += parseInt(numbers.charAt(length - i)) * pos--;
+    if (pos < 2) pos = 9;
+  }
+  let result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+  if (result !== parseInt(digits.charAt(0))) return false;
+  length = length + 1;
+  numbers = cnpj.substring(0, length);
+  sum = 0;
+  pos = length - 7;
+  for (let i = length; i >= 1; i--) {
+    sum += parseInt(numbers.charAt(length - i)) * pos--;
+    if (pos < 2) pos = 9;
+  }
+  result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+  if (result !== parseInt(digits.charAt(1))) return false;
+  return true;
+}

+ 7 - 0
src/css/app.scss

@@ -83,3 +83,10 @@ input[type="number"]::-webkit-outer-spin-button {
   flex-direction: column;
   flex: 1 1 auto !important;
 }
+
+.remove-header-expansion-item {
+  .q-list--dense > .q-item,
+  .q-item--dense {
+    display: none;
+  }
+}

+ 2 - 1
src/helpers/utils.js

@@ -25,7 +25,8 @@ const formatDateDMYtoYMD = (date, time) => {
   if (!date) throw new Error(useI18n().t("validation.rules.required"));
   const testDate =
     /^([0-2][0-9]|(3)[0-1])(\/)(((0)[0-9])|((1)[0-2]))(\/)\d{4}$/;
-  if (testDate.test(date) === false) throw new Error(useI18n().t("validation.rules.date"));
+  if (testDate.test(date) === false)
+    throw new Error(useI18n().t("validation.rules.date"));
 
   const [day, month, year] = date.split("/");
   return `${year}-${month}-${day} ${time ? time : ""}`;

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

@@ -55,9 +55,31 @@
       "banner": "Banner",
       "logo": "Logo",
       "media": "Media",
+      "month": "Month",
+      "week": "Week",
+      "day": "Day",
+      "hour": "Hour",
+      "minute": "Minute",
+      "second": "Second",
+      "year": "Year",
+      "all": "All",
       "certificate": "Certificate",
       "version": "Version"
     },
+    "months": {
+      "january": "January",
+      "february": "February",
+      "march": "March",
+      "april": "April",
+      "may": "May",
+      "june": "June",
+      "july": "July",
+      "august": "August",
+      "september": "September",
+      "october": "October",
+      "november": "November",
+      "december": "December"
+    },
     "status": {
       "active": "Active",
       "inactive": "Inactive",
@@ -84,6 +106,7 @@
       "messages": {
         "copied_to_clipboard": "Copied to clipboard",
         "confirm_action": "Are you sure?",
+        "are_you_sure_delete": "Are you sure you want to delete this item?",
         "welcome": "Welcome",
         "enjoy_the_event": "Enjoy the event!"
       },
@@ -93,7 +116,7 @@
         "example": "Example",
         "options": "Options",
         "total": "Total",
-        "type": "Tipo"
+        "type": "Type"
       }
     },
     "metadata": {
@@ -127,6 +150,7 @@
       "characters": "characters",
       "password": "Password must have at least 6 characters, one uppercase letter, one lowercase letter and one number",
       "same_password": "Passwords must match",
+      "not_same_document": "The document must be unique for each participant",
       "cpf": "This field must be a valid CPF",
       "cnpj": "This field must be a valid CNPJ",
       "cep": "This field must be a valid ZIP code",
@@ -256,5 +280,56 @@
       "country": "Country",
       "exit": "Exit"
     }
+  },
+  "charts": {
+    "nps": {
+      "promotion_zone": "Promotion Zone",
+      "promotion_zone_range": "Promotion Zone Range",
+      "quality_zone": "Quality Zone",
+      "quality_zone_range": "Quality Zone Range",
+      "refinement_zone": "Refinement Zone",
+      "refinement_zone_range": "Refinement Zone Range",
+      "critical_zone": "Critical Zone",
+      "critical_zone_range": "Critical Zone Range",
+      "promoters": "Promoters",
+      "passives": "Passives",
+      "detractors": "Detractors"
+    }
+  },
+  "dashboard": {
+    "currency_format": "$ {value}",
+    "cards": {
+      "total_earnings": "Total Earnings",
+      "tickets_sold": "Tickets Sold",
+      "registrations": "Registrations"
+    },
+    "charts": {
+      "tickets_by_type": {
+        "title": "Total tickets by type",
+        "labels": {
+          "vip": "VIP",
+          "track": "Track",
+          "box": "Box",
+          "courtesy": "Courtesy"
+        }
+      },
+      "participants_by_document": {
+        "title": "Percentage of CNPJ and CPF in registrations"
+      },
+      "sales_over_time": {
+        "title": "Sales Over Period",
+        "y_label": "Value ({currency})"
+      },
+      "registration_source": {
+        "title": "Registration Source",
+        "source": "Source",
+        "sources": {
+          "instagram": "Instagram",
+          "facebook": "Facebook",
+          "google": "Google",
+          "referral": "Referral"
+        }
+      }
+    }
   }
 }

+ 134 - 59
src/i18n/locales/es.json

@@ -12,17 +12,17 @@
       "next": "Siguiente",
       "resend_email": "Reenviar correo electrónico",
       "download_certificate": "Descargar certificado",
-      "download_boleto": "Descargar Boleto/Recibo",
-      "copy_paste_code": "Copia y pega el código a continuación para realizar el pago"
+      "download_boleto": "Descargar Boleto",
+      "copy_paste_code": "Copie y pegue el código a continuación para realizar el pago"
     },
     "terms": {
       "name": "Nombre",
-      "email": "Correo Electrónico",
+      "email": "Correo electrónico",
       "password": "Contraseña",
       "description": "Descripción",
       "date": "Fecha",
-      "start_date": "Fecha de Inicio",
-      "end_date": "Fecha de Fin",
+      "start_date": "Fecha de inicio",
+      "end_date": "Fecha de fin",
       "code": "Código",
       "title": "Título",
       "status": "Estado",
@@ -34,30 +34,52 @@
       "address": "Dirección",
       "address_number": "Número",
       "complement": "Complemento",
-      "postal_code": "Código Postal",
+      "postal_code": "Código postal",
       "phone": "Teléfono",
       "document": "Documento",
-      "document_type": "Tipo de Documento",
-      "cpf": "CPF/Identificación Fiscal",
-      "cnpj": "CNPJ/Identificación Fiscal Empresa",
+      "document_type": "Tipo de documento",
+      "cpf": "CPF",
+      "cnpj": "CNPJ",
       "cep": "Código Postal",
-      "order_number": "Número de Pedido",
-      "order_amount": "Importe del Pedido",
-      "total_amount": "Importe Total",
+      "order_number": "Número de pedido",
+      "order_amount": "Monto del pedido",
+      "total_amount": "Monto total",
       "payment": "Pago",
-      "payment_method": "Método de Pago",
-      "payment_date": "Fecha de Pago",
-      "payment_amount": "Importe del Pago",
+      "payment_method": "Método de pago",
+      "payment_date": "Fecha de pago",
+      "payment_amount": "Monto del pago",
       "language": "Idioma",
       "currency": "Moneda",
       "interests": "Intereses",
       "avatar": "Avatar",
       "banner": "Banner",
       "logo": "Logo",
-      "media": "Medios",
+      "media": "Media",
+      "month": "Mes",
+      "week": "Semana",
+      "day": "Día",
+      "hour": "Hora",
+      "minute": "Minuto",
+      "second": "Segundo",
+      "year": "Año",
+      "all": "Todos",
       "certificate": "Certificado",
       "version": "Versión"
     },
+    "months": {
+      "january": "Enero",
+      "february": "Febrero",
+      "march": "Marzo",
+      "april": "Abril",
+      "may": "Mayo",
+      "june": "Junio",
+      "july": "Julio",
+      "august": "Agosto",
+      "september": "Septiembre",
+      "october": "Octubre",
+      "november": "Noviembre",
+      "december": "Diciembre"
+    },
     "status": {
       "active": "Activo",
       "inactive": "Inactivo",
@@ -68,12 +90,12 @@
     },
     "ui": {
       "file": {
-        "choose": "Elige un archivo",
-        "click_select": "Haz clic para seleccionar un archivo",
-        "click_select_image": "Haz clic para seleccionar una imagen",
-        "drag": "Arrastra",
-        "drag_and_drop": "Arrastra y suelta el archivo aquí",
-        "drag_here": "Arrastra el archivo aquí",
+        "choose": "Elegir un archivo",
+        "click_select": "Haga clic para seleccionar un archivo",
+        "click_select_image": "Haga clic para seleccionar una imagen",
+        "drag": "Arrastrar",
+        "drag_and_drop": "Arrastre y suelte el archivo aquí",
+        "drag_here": "Arrastre el archivo aquí",
         "selected": "Archivo seleccionado"
       },
       "table": {
@@ -84,8 +106,9 @@
       "messages": {
         "copied_to_clipboard": "Copiado al portapapeles",
         "confirm_action": "¿Estás seguro?",
-        "welcome": "Bienvenido(a)",
-        "enjoy_the_event": "¡Disfruta del evento!"
+        "are_you_sure_delete": "¿Estás seguro de que quieres eliminar este elemento?",
+        "welcome": "Bienvenido",
+        "enjoy_the_event": "¡Disfruta el evento!"
       },
       "misc": {
         "all": "Todos",
@@ -103,16 +126,16 @@
     }
   },
   "auth": {
-    "login": "Iniciar Sesión",
-    "logout": "Cerrar Sesión",
+    "login": "Iniciar sesión",
+    "logout": "Cerrar sesión",
     "registration": "Registro",
-    "confirm_password": "Confirmar Contraseña",
+    "confirm_password": "Confirmar contraseña",
     "agreed_terms": "Acepto los términos",
     "agreed_privacy": "Acepto la política de privacidad"
   },
   "business": {
     "advertise": "Anunciar",
-    "my_advertisements": "Mis Anuncios",
+    "my_advertisements": "Mis anuncios",
     "negotiations": "Negociaciones",
     "opportunities": "Oportunidades",
     "plans": "Planes"
@@ -127,9 +150,10 @@
       "characters": "caracteres",
       "password": "La contraseña debe tener al menos 6 caracteres, una letra mayúscula, una letra minúscula y un número",
       "same_password": "Las contraseñas deben coincidir",
-      "cpf": "Este campo debe ser un CPF/Identificación Fiscal válido",
-      "cnpj": "Este campo debe ser un CNPJ/Identificación Fiscal de Empresa válido",
-      "cep": "Este campo debe ser un Código Postal válido",
+      "not_same_document": "El documento debe ser único para cada participante",
+      "cpf": "Este campo debe ser un CPF válido",
+      "cnpj": "Este campo debe ser un CNPJ válido",
+      "cep": "Este campo debe ser un código postal válido",
       "value_smaller_than_zero": "El valor no puede ser menor que cero"
     },
     "permissions": {
@@ -146,17 +170,17 @@
       "failed": "La acción falló",
       "no_records_found": "No se encontraron registros"
     },
-    "success": "La acción se realizó con éxito"
+    "success": "La acción fue exitosa"
   },
   "events": {
     "singular": "Evento",
     "plural": "Eventos",
     "core": {
-      "basic_information": "Información Básica",
+      "basic_information": "Información básica",
       "schedule": "Programa",
       "opening": "Apertura",
-      "total_capacity": "Capacidad Total",
-      "unique_code": "Código Único",
+      "total_capacity": "Capacidad total",
+      "unique_code": "Código único",
       "unique_code_hint": "Este código se genera automáticamente",
       "list_of_allowed_documents": "Lista de documentos permitidos"
     },
@@ -168,12 +192,12 @@
       "event_ticket": "Entrada del Evento",
       "event_tickets": "Entradas del Evento",
       "event_ticket_types": "Tipos de Entrada del Evento",
-      "sales_start_date": "Fecha de Inicio de Ventas",
-      "sales_end_date": "Fecha de Fin de Ventas",
-      "max_per_user": "Máximo de Entradas por Usuario",
+      "sales_start_date": "Fecha de inicio de ventas",
+      "sales_end_date": "Fecha de fin de ventas",
+      "max_per_user": "Máximo de entradas por usuario",
       "max_per_user_hint": "0 para ilimitado",
-      "quantity_available": "Cantidad Disponible",
-      "quantity_sold": "Cantidad Vendida"
+      "quantity_available": "Cantidad disponible",
+      "quantity_sold": "Cantidad vendida"
     },
     "location": {
       "singular": "Ubicación"
@@ -181,8 +205,8 @@
     "attendance": {
       "participant_singular": "Participante",
       "participant_plural": "Participantes",
-      "checked_in_at": "Check-in realizado el",
-      "is_checked_in": "Check-in realizado"
+      "checked_in_at": "Registrado el",
+      "is_checked_in": "Está registrado"
     }
   },
   "user": {
@@ -191,8 +215,8 @@
     "profile": {
       "singular": "Perfil",
       "name_and_surname": "Nombre y Apellido",
-      "birth_date": "Fecha de Nacimiento",
-      "personal_information": "Información Personal"
+      "birth_date": "Fecha de nacimiento",
+      "personal_information": "Información personal"
     },
     "preferences": {
       "singular": "Preferencias"
@@ -202,16 +226,16 @@
     "singular": "Pedido",
     "plural": "Pedidos",
     "core": {
-      "new_order": "Nuevo Pedido",
-      "payment_received": "Pago Recibido",
-      "resume": "Resumen del Pedido",
-      "buyer_information": "Información del Comprador",
-      "participant_information": "Información del Participante",
+      "new_order": "Nuevo pedido",
+      "payment_received": "Pago recibido",
+      "resume": "Resumen del pedido",
+      "buyer_information": "Información del comprador",
+      "participant_information": "Información del participante",
       "same_as_buyer": "Igual que el comprador",
-      "select_at_least_one_ticket": "Selecciona al menos una entrada",
-      "select_payment_method": "Selecciona un método de pago",
+      "select_at_least_one_ticket": "Seleccione al menos una entrada",
+      "select_payment_method": "Seleccione un método de pago",
       "exclusive_list": "Lista exclusiva",
-      "successful_payment": "Pago realizado con éxito"
+      "successful_payment": "Pago exitoso"
     },
     "statuses": {
       "paid": "Pagado",
@@ -223,9 +247,9 @@
       "confirmation": "Confirmación"
     },
     "payment_methods": {
-      "credit_card": "Tarjeta de Crédito",
-      "boleto": "Boleto/Recibo",
-      "pix": "Pix/Transferencia Instantánea"
+      "credit_card": "Tarjeta de crédito",
+      "boleto": "Boleto",
+      "pix": "Pix"
     }
   },
   "ui": {
@@ -235,13 +259,13 @@
       "dashboard": "Panel",
       "explore": "Explorar",
       "advertise": "Anunciar",
-      "my_advertisements": "Mis Anuncios",
+      "my_advertisements": "Mis anuncios",
       "negotiations": "Negociaciones",
       "opportunities": "Oportunidades",
       "plans": "Planes",
       "events": "Eventos",
-      "event_tickets": "Entradas",
-      "event_ticket_types": "Tipos de Entrada",
+      "event_tickets": "Entradas del Evento",
+      "event_ticket_types": "Tipos de Entrada del Evento",
       "orders": "Pedidos",
       "sales": "Ventas",
       "participants": "Participantes",
@@ -249,12 +273,63 @@
       "profile": "Perfil",
       "interests": "Intereses",
       "registration": "Registro",
-      "wallet": "Cartera",
+      "wallet": "Billetera",
       "settings": "Configuración",
       "city": "Ciudad",
       "state": "Estado/Provincia",
       "country": "País",
       "exit": "Salir"
     }
+  },
+  "charts": {
+    "nps": {
+      "promotion_zone": "Zona de Promoción",
+      "promotion_zone_range": "Rango de Zona de Promoción",
+      "quality_zone": "Zona de Calidad",
+      "quality_zone_range": "Rango de Zona de Calidad",
+      "refinement_zone": "Zona de Refinamiento",
+      "refinement_zone_range": "Rango de Zona de Refinamiento",
+      "critical_zone": "Zona Crítica",
+      "critical_zone_range": "Rango de Zona Crítica",
+      "promoters": "Promotores",
+      "passives": "Pasivos",
+      "detractors": "Detractores"
+    }
+  },
+  "dashboard": {
+    "currency_format": "R$ {value}",
+    "cards": {
+      "total_earnings": "Ingresos Totales",
+      "tickets_sold": "Entradas Vendidas",
+      "registrations": "Inscripciones"
+    },
+    "charts": {
+      "tickets_by_type": {
+        "title": "Total de entradas por tipo",
+        "labels": {
+          "vip": "VIP",
+          "track": "Pista",
+          "box": "Palco",
+          "courtesy": "Cortesía"
+        }
+      },
+      "participants_by_document": {
+        "title": "Porcentaje de CNPJ y CPF en las inscripciones"
+      },
+      "sales_over_time": {
+        "title": "Ventas por Período",
+        "y_label": "Valor ({currency})"
+      },
+      "registration_source": {
+        "title": "Origen de la Inscripción",
+        "source": "Origen",
+        "sources": {
+          "instagram": "Instagram",
+          "facebook": "Facebook",
+          "google": "Google",
+          "referral": "Indicación"
+        }
+      }
+    }
   }
 }

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

@@ -13,7 +13,7 @@
       "resend_email": "Reenviar e-mail",
       "download_certificate": "Baixar certificado",
       "download_boleto": "Baixar Boleto",
-      "copy_paste_code": "Copie e cole o código abaixo para realizar o pagamento PIX"
+      "copy_paste_code": "Copie e cole o código abaixo para efetuar o pagamento"
     },
     "terms": {
       "name": "Nome",
@@ -34,7 +34,7 @@
       "address": "Endereço",
       "address_number": "Número",
       "complement": "Complemento",
-      "postal_code": "CEP",
+      "postal_code": "Código Postal",
       "phone": "Telefone",
       "document": "Documento",
       "document_type": "Tipo de Documento",
@@ -55,14 +55,36 @@
       "banner": "Banner",
       "logo": "Logo",
       "media": "Mídia",
+      "month": "Mês",
+      "week": "Semana",
+      "day": "Dia",
+      "hour": "Hora",
+      "minute": "Minuto",
+      "second": "Segundo",
+      "year": "Ano",
+      "all": "Todos",
       "certificate": "Certificado",
       "version": "Versão"
     },
+    "months": {
+      "january": "Janeiro",
+      "february": "Fevereiro",
+      "march": "Março",
+      "april": "Abril",
+      "may": "Maio",
+      "june": "Junho",
+      "july": "Julho",
+      "august": "Agosto",
+      "september": "Setembro",
+      "october": "Outubro",
+      "november": "Novembro",
+      "december": "Dezembro"
+    },
     "status": {
       "active": "Ativo",
       "inactive": "Inativo",
       "canceled": "Cancelado",
-      "loading": "Aguarde...",
+      "loading": "Por favor, aguarde...",
       "yes": "Sim",
       "no": "Não"
     },
@@ -84,7 +106,8 @@
       "messages": {
         "copied_to_clipboard": "Copiado para a área de transferência",
         "confirm_action": "Você tem certeza?",
-        "welcome": "Bem-vindo(a)",
+        "are_you_sure_delete": "Tem certeza de que deseja excluir este item?",
+        "welcome": "Bem-vindo",
         "enjoy_the_event": "Aproveite o evento!"
       },
       "misc": {
@@ -107,8 +130,8 @@
     "logout": "Sair",
     "registration": "Cadastro",
     "confirm_password": "Confirmar Senha",
-    "agreed_terms": "Concordo com os termos",
-    "agreed_privacy": "Concordo com a política de privacidade"
+    "agreed_terms": "Eu concordo com os termos",
+    "agreed_privacy": "Eu concordo com a política de privacidade"
   },
   "business": {
     "advertise": "Anunciar",
@@ -125,8 +148,9 @@
       "min": "Este campo deve ter no mínimo",
       "max": "Este campo deve ter no máximo",
       "characters": "caracteres",
-      "password": "A senha deve ter no mínimo 6 caracteres, uma letra maiúscula, uma letra minúscula e um número",
+      "password": "A senha deve ter pelo menos 6 caracteres, uma letra maiúscula, uma letra minúscula e um número",
       "same_password": "As senhas devem ser iguais",
+      "not_same_document": "O documento deve ser único para cada participante",
       "cpf": "Este campo deve ser um CPF válido",
       "cnpj": "Este campo deve ser um CNPJ válido",
       "cep": "Este campo deve ser um CEP válido",
@@ -181,7 +205,7 @@
     "attendance": {
       "participant_singular": "Participante",
       "participant_plural": "Participantes",
-      "checked_in_at": "Check-in realizado em",
+      "checked_in_at": "Check-in em",
       "is_checked_in": "Check-in realizado"
     }
   },
@@ -207,11 +231,11 @@
       "resume": "Resumo do Pedido",
       "buyer_information": "Informações do Comprador",
       "participant_information": "Informações do Participante",
-      "same_as_buyer": "Mesmo que o comprador",
+      "same_as_buyer": "O mesmo que o comprador",
       "select_at_least_one_ticket": "Selecione pelo menos um ingresso",
       "select_payment_method": "Selecione um método de pagamento",
       "exclusive_list": "Lista exclusiva",
-      "successful_payment": "Pagamento realizado com sucesso"
+      "successful_payment": "Pagamento bem-sucedido"
     },
     "statuses": {
       "paid": "Pago",
@@ -240,7 +264,7 @@
       "opportunities": "Oportunidades",
       "plans": "Planos",
       "events": "Eventos",
-      "event_tickets": "Ingressos",
+      "event_tickets": "Ingressos do Evento",
       "event_ticket_types": "Tipos de Ingresso",
       "orders": "Pedidos",
       "sales": "Vendas",
@@ -256,5 +280,56 @@
       "country": "País",
       "exit": "Sair"
     }
+  },
+  "charts": {
+    "nps": {
+      "promotion_zone": "Zona de Promoção",
+      "promotion_zone_range": "Faixa da Zona de Promoção",
+      "quality_zone": "Zona de Qualidade",
+      "quality_zone_range": "Faixa da Zona de Qualidade",
+      "refinement_zone": "Zona de Aperfeiçoamento",
+      "refinement_zone_range": "Faixa da Zona de Aperfeiçoamento",
+      "critical_zone": "Zona Crítica",
+      "critical_zone_range": "Faixa da Zona Crítica",
+      "promoters": "Promotores",
+      "passives": "Passivos",
+      "detractors": "Detratores"
+    }
+  },
+  "dashboard": {
+    "currency_format": "R$ {value}",
+    "cards": {
+      "total_earnings": "Total Ganho",
+      "tickets_sold": "Ingressos Vendidos",
+      "registrations": "Cadastros"
+    },
+    "charts": {
+      "tickets_by_type": {
+        "title": "Total de ingressos por tipo",
+        "labels": {
+          "vip": "VIP",
+          "track": "Pista",
+          "box": "Camarote",
+          "courtesy": "Cortesia"
+        }
+      },
+      "participants_by_document": {
+        "title": "Porcentagem de CNPJ e CPF por cadastros"
+      },
+      "sales_over_time": {
+        "title": "Vendas por Período",
+        "y_label": "Valor ({currency})"
+      },
+      "registration_source": {
+        "title": "Origem dos Cadastros",
+        "source": "Origem",
+        "sources": {
+          "instagram": "Instagram",
+          "facebook": "Facebook",
+          "google": "Google",
+          "referral": "Indicação"
+        }
+      }
+    }
   }
 }

+ 5 - 5
src/layouts/MainLayout.vue

@@ -34,7 +34,9 @@
                       style="font-size: 18px"
                     />
                   </q-item-section>
-                  <q-item-section>{{ $t("user.profile.singular") }}</q-item-section>
+                  <q-item-section>{{
+                    $t("user.profile.singular")
+                  }}</q-item-section>
                 </div>
               </q-item>
               <q-item v-ripple clickable @click="logoutFn">
@@ -46,7 +48,7 @@
                       style="font-size: 18px"
                     />
                   </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>
@@ -69,9 +71,7 @@
               <component
                 :is="Component"
                 style="padding: 20px !important; padding-right: 10px !important"
-                :style="
-                  $q.screen.lt.sm ? 'padding-left: 10px !important;' : ''
-                "
+                :style="$q.screen.lt.sm ? 'padding-left: 10px !important;' : ''"
               />
             </Transition>
           </router-view>

+ 366 - 9
src/pages/dashboard/DashboardPage.vue

@@ -1,22 +1,379 @@
 <template>
   <div>
-    <DefaultHeaderPage />
-    <div class="flex gap q-pa-sm">
-      <div class="flex flex-grow gap">
-        <CardIconChart class="flex-grow" />
-        <CardIconChart class="flex-grow" />
+    <DefaultHeaderPage>
+      <template #after>
+        <q-btn
+          outline
+          icon="mdi-calendar"
+          color="primary"
+          @click="showFilter"
+        />
+      </template>
+    </DefaultHeaderPage>
+    <q-expansion-item
+      v-model="filter"
+      dense
+      hide-expand-icon
+      class="remove-header-expansion-item"
+    >
+      <DatePeriodSelector
+        v-model:selected-period="defaultPeriod"
+        v-model:selected-event-id="defaultEventId"
+        class="q-pa-sm"
+      />
+    </q-expansion-item>
+
+    <div v-if="!isLoading" class="column gap q-pa-sm">
+      <div class="flex full-width gap">
+        <div class="flex flex-grow gap">
+          <CardIconMiniChart
+            class="flex-grow"
+            :title="t('dashboard.cards.total_earnings')"
+            :icon="'mdi-currency-usd'"
+            :number-porcent="paymentsChart.percentage_change"
+            :number-card="
+              t('dashboard.currency_format', {
+                value: paymentsChart.current_total,
+              })
+            "
+          >
+            <template #chart>
+              <MiniLineChart
+                :data="paymentsChart.trend_data"
+                fill-color="rgba(0, 0, 0, 0)"
+              />
+            </template>
+          </CardIconMiniChart>
+          <CardIconMiniChart
+            class="flex-grow"
+            :title="t('orders.plural')"
+            :icon="'mdi-package-variant'"
+            :number-porcent="ordersChart.percentage_change"
+            :number-card="ordersChart.current_total"
+          >
+            <template #chart>
+              <MiniBarChart
+                :data="ordersChart.trend_data"
+                fill-color="rgba(0, 0, 0, 0)"
+              />
+            </template>
+          </CardIconMiniChart>
+        </div>
+        <div class="flex flex-grow gap">
+          <CardIconMiniChart
+            class="flex-grow"
+            :title="t('dashboard.cards.tickets_sold')"
+            :icon="'mdi-ticket-outline'"
+            :number-porcent="ticketsSoldChart.percentage_change"
+            :number-card="ticketsSoldChart.current_total"
+          >
+            <template #chart>
+              <MiniLineChart
+                :data="ticketsSoldChart.trend_data"
+                fill-color="rgba(0, 0, 0, 0)"
+              />
+            </template>
+          </CardIconMiniChart>
+          <CardIconMiniChart
+            class="flex-grow"
+            :title="t('dashboard.cards.registrations')"
+            :icon="'mdi-account-group-outline'"
+            :number-porcent="participantsChart.percentage_change"
+            :number-card="participantsChart.current_total"
+          >
+            <template #chart>
+              <MiniBarChart
+                :data="participantsChart.trend_data"
+                fill-color="rgba(0, 0, 0, 0)"
+              />
+            </template>
+          </CardIconMiniChart>
+        </div>
       </div>
-      <div class="flex flex-grow gap">
-        <CardIconChart class="flex-grow" />
-        <CardIconChart class="flex-grow" />
+
+      <div class="flex full-width gap">
+        <div class="flex flex-grow">
+          <CardIconChart
+            :title="t('dashboard.charts.tickets_by_type.title')"
+            :icon="'mdi-ticket-account'"
+            class="flex-grow"
+          >
+            <template #chart>
+              <BarChart
+                :data="eventTicketsByTypeChart"
+                :data-set-label="t('events.tickets.plural')"
+                :label-x="t('events.tickets.types_singular')"
+                :label-y="t('common.terms.quantity')"
+                :show-legend="true"
+              />
+            </template>
+          </CardIconChart>
+        </div>
+        <div class="flex flex-grow">
+          <CardIconChart
+            :title="t('dashboard.charts.participants_by_document.title')"
+            :icon="'mdi-badge-account'"
+            class="flex-grow"
+          >
+            <template #chart>
+              <DoughnutChart
+                :data="eventParticipantsByCNPJAndCPF"
+                :data-set-label="t('events.attendance.participant_plural')"
+              />
+            </template>
+          </CardIconChart>
+        </div>
       </div>
+
+      <div class="flex full-width gap">
+        <div class="flex flex-grow">
+          <CardIconChart
+            :title="t('dashboard.charts.sales_over_time.title')"
+            :icon="'mdi-chart-line'"
+            class="flex-grow"
+          >
+            <template #chart>
+              <LineChart
+                :data="salesOverTimeLineChart"
+                :data-set-label="t('ui.navigation.sales')"
+                :label-x="t('common.terms.month')"
+                :label-y="
+                  t('dashboard.charts.sales_over_time.y_label', {
+                    currency: 'R$',
+                  })
+                "
+              />
+            </template>
+          </CardIconChart>
+        </div>
+        <div class="flex flex-grow">
+          <CardIconChart
+            :title="t('dashboard.charts.registration_source.title')"
+            :icon="'mdi-chart-pie'"
+            class="flex-grow"
+          >
+            <template #chart>
+              <PieChart
+                :data="eventSourcePieChart"
+                :data-set-label="
+                  t('dashboard.charts.registration_source.source')
+                "
+              />
+            </template>
+          </CardIconChart>
+        </div>
+      </div>
+    </div>
+
+    <div v-else class="flex flex-center full-width q-pa-xl">
+      <q-spinner color="primary" size="50px" />
     </div>
   </div>
 </template>
+
 <script setup>
+import { onMounted, ref, watch, defineAsyncComponent } from "vue";
+import { useI18n } from "vue-i18n";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import CardIconChart from "src/components/charts/CardIconChart.vue";
+import DatePeriodSelector from "./components/DatePeriodSelector.vue";
+
+const MiniLineChart = defineAsyncComponent(
+  () => import("src/components/charts/mini/MiniLineChart.vue"),
+);
+const MiniBarChart = defineAsyncComponent(
+  () => import("src/components/charts/mini/MiniBarChart.vue"),
+);
+const CardIconMiniChart = defineAsyncComponent(
+  () => import("src/components/charts/CardIconMiniChart.vue"),
+);
+const CardIconChart = defineAsyncComponent(
+  () => import("src/components/charts/CardIconChart.vue"),
+);
+const BarChart = defineAsyncComponent(
+  () => import("src/components/charts/normal/BarChart.vue"),
+);
+const DoughnutChart = defineAsyncComponent(
+  () => import("src/components/charts/normal/DoughnutChart.vue"),
+);
+const LineChart = defineAsyncComponent(
+  () => import("src/components/charts/normal/LineChart.vue"),
+);
+const PieChart = defineAsyncComponent(
+  () => import("src/components/charts/normal/PieChart.vue"),
+);
+
+const { t } = useI18n();
+
+const isLoading = ref(true);
+const filter = ref(false);
+const defaultPeriod = ref("month");
+const defaultEventId = ref(1);
+
+const ordersChart = ref({});
+const participantsChart = ref({});
+const paymentsChart = ref({});
+const ticketsSoldChart = ref({});
+const eventTicketsByTypeChart = ref({});
+const eventParticipantsByCNPJAndCPF = ref({});
+const salesOverTimeLineChart = ref({});
+const eventSourcePieChart = ref({});
+
+const showFilter = () => {
+  filter.value = !filter.value;
+};
+
+const generateMockData = () => {
+  const createMiniChartData = (currentTotal, percentage) => ({
+    current_total: currentTotal,
+    percentage_change: percentage,
+    trend_data: Array.from({ length: 10 }, () =>
+      Math.floor(Math.random() * 100),
+    ),
+  });
+
+  const barChartDataRaw = [
+    {
+      label: t("dashboard.charts.tickets_by_type.labels.vip"),
+      value: Math.floor(Math.random() * 300),
+    },
+    {
+      label: t("dashboard.charts.tickets_by_type.labels.track"),
+      value: Math.floor(Math.random() * 800),
+    },
+    {
+      label: t("dashboard.charts.tickets_by_type.labels.box"),
+      value: Math.floor(Math.random() * 400),
+    },
+    {
+      label: t("dashboard.charts.tickets_by_type.labels.courtesy"),
+      value: Math.floor(Math.random() * 50),
+    },
+  ];
+
+  const doughnutDataRaw = [
+    {
+      label: t("common.terms.cpf"),
+      value: Math.floor(Math.random() * 900 + 100),
+    },
+    {
+      label: t("common.terms.cnpj"),
+      value: Math.floor(Math.random() * 100 + 10),
+    },
+  ];
+  const doughnutTotal = doughnutDataRaw.reduce(
+    (sum, item) => sum + item.value,
+    0,
+  );
+
+  const lineChartDataRaw = [
+    {
+      label: t("common.months.january"),
+      value: Math.floor(1200 + Math.random() * 500),
+    },
+    {
+      label: t("common.months.february"),
+      value: Math.floor(1900 + Math.random() * 500),
+    },
+    {
+      label: t("common.months.march"),
+      value: Math.floor(3000 + Math.random() * 500),
+    },
+    {
+      label: t("common.months.april"),
+      value: Math.floor(5000 + Math.random() * 500),
+    },
+    {
+      label: t("common.months.may"),
+      value: Math.floor(2300 + Math.random() * 500),
+    },
+    {
+      label: t("common.months.june"),
+      value: Math.floor(3200 + Math.random() * 500),
+    },
+  ];
+
+  const pieDataRaw = [
+    {
+      label: t("dashboard.charts.registration_source.sources.instagram"),
+      value: Math.floor(450 + Math.random() * 50),
+    },
+    {
+      label: t("dashboard.charts.registration_source.sources.facebook"),
+      value: Math.floor(250 + Math.random() * 50),
+    },
+    {
+      label: t("dashboard.charts.registration_source.sources.google"),
+      value: Math.floor(180 + Math.random() * 50),
+    },
+    {
+      label: t("dashboard.charts.registration_source.sources.referral"),
+      value: Math.floor(120 + Math.random() * 50),
+    },
+  ];
+  const pieTotal = pieDataRaw.reduce((sum, item) => sum + item.value, 0);
+
+  return {
+    payments: createMiniChartData(
+      (Math.random() * 20000 + 5000).toFixed(2),
+      (Math.random() * 20 - 5).toFixed(2),
+    ),
+    orders: createMiniChartData(
+      Math.floor(Math.random() * 500 + 50),
+      (Math.random() * 15 - 5).toFixed(2),
+    ),
+    tickets_sold: createMiniChartData(
+      Math.floor(Math.random() * 1500 + 200),
+      (Math.random() * 25 - 5).toFixed(2),
+    ),
+    participants: createMiniChartData(
+      Math.floor(Math.random() * 1000 + 100),
+      (Math.random() * 10 - 5).toFixed(2),
+    ),
+    barData: {
+      chart_data: barChartDataRaw,
+    },
+    doughnutData: {
+      chart_data: doughnutDataRaw,
+      current_total: doughnutTotal,
+    },
+    lineData: {
+      chart_data: lineChartDataRaw,
+    },
+    pieData: {
+      chart_data: pieDataRaw,
+      current_total: pieTotal,
+    },
+  };
+};
+
+const updateDashboardData = async () => {
+  isLoading.value = true;
+  setTimeout(() => {
+    const mockData = generateMockData();
+
+    ordersChart.value = mockData.orders;
+    participantsChart.value = mockData.participants;
+    paymentsChart.value = mockData.payments;
+    ticketsSoldChart.value = mockData.tickets_sold;
+
+    eventTicketsByTypeChart.value = mockData.barData;
+    eventParticipantsByCNPJAndCPF.value = mockData.doughnutData;
+    salesOverTimeLineChart.value = mockData.lineData;
+    eventSourcePieChart.value = mockData.pieData;
+
+    isLoading.value = false;
+  }, 500);
+};
+
+watch([defaultPeriod, defaultEventId], async () => {
+  await updateDashboardData();
+});
+
+onMounted(async () => {
+  await updateDashboardData();
+});
 </script>
+
 <style scoped>
 .gap {
   gap: 16px;

+ 44 - 0
src/pages/dashboard/components/DatePeriodSelector.vue

@@ -0,0 +1,44 @@
+<template>
+  <div class="row q-col-gutter-md items-center">
+    <div class="col-12 col-sm-6 col-md-3">
+      <q-btn-group outline class="full-width">
+        <q-btn
+          v-for="option in periodOptions"
+          :key="option.value"
+          :outline="selectedPeriod != option.value"
+          :label="option.label"
+          color="primary"
+          class="full-width"
+          @click="selectedPeriod = option.value"
+        />
+      </q-btn-group>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { useI18n } from "vue-i18n";
+
+const { t } = useI18n();
+
+const selectedPeriod = defineModel("selectedPeriod", {
+  type: String,
+  default: "month",
+});
+const selectedEventId = defineModel("selectedEventId");
+
+const selectedEvent = ref(null);
+
+watch(selectedEvent, () => {
+  selectedEventId.value = selectedEvent.value?.value;
+});
+
+const periodOptions = [
+  { label: t("common.terms.day"), value: "day" },
+  { label: t("common.terms.week"), value: "week" },
+  { label: t("common.terms.month"), value: "month" },
+  { label: t("common.terms.year"), value: "year" },
+  { label: t("common.terms.all"), value: "all" },
+];
+</script>

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