Browse Source

more charts and improvments to the useFormUpdateTracker

DenLopes 10 tháng trước cách đây
mục cha
commit
25400777c4
41 tập tin đã thay đổi với 4114 bổ sung518 xóa
  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. 22 4
      src/components/defaults/DefaultInputDatePicker.vue
  27. 77 30
      src/components/defaults/DefaultTable.vue
  28. 4 2
      src/components/layout/DefaultHeaderPage.vue
  29. 70 64
      src/components/layout/LeftMenuLayout.vue
  30. 1 9
      src/components/regions/CountrySelect.vue
  31. 1 1
      src/composables/useAuth.js
  32. 87 11
      src/composables/useFormUpdateTracker.js
  33. 70 8
      src/composables/useInputRules.js
  34. 7 0
      src/css/app.scss
  35. 2 1
      src/helpers/utils.js
  36. 76 1
      src/i18n/locales/en.json
  37. 134 59
      src/i18n/locales/es.json
  38. 86 11
      src/i18n/locales/pt.json
  39. 5 5
      src/layouts/MainLayout.vue
  40. 366 9
      src/pages/dashboard/DashboardPage.vue
  41. 44 0
      src/pages/dashboard/components/DatePeriodSelector.vue

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 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>

+ 22 - 4
src/components/defaults/DefaultInputDatePicker.vue

@@ -1,10 +1,12 @@
 <template>
   <div v-bind="$attrs">
     <q-input
+      ref="inputRef"
       v-model="treatedDate"
-      :mask="time ? masks.Brasil.datetime : masks.Brasil.date"
+      :mask="inputMask"
       :label="label"
       :rules="rules"
+      :dense="dense"
       clearable
     >
       <template #append>
@@ -50,11 +52,11 @@
 </template>
 
 <script setup>
-import { watch, ref } from "vue";
+import { watch, ref, computed, useTemplateRef } from "vue";
 import { useI18n } from "vue-i18n";
 import masks from "src/helpers/masks";
 
-const { label, rules, time } = defineProps({
+const { label, rules, time, dense } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("common.terms.date"),
@@ -63,16 +65,23 @@ const { label, rules, time } = defineProps({
     type: Array,
     default: () => [],
   },
+  dense: {
+    type: Boolean,
+    default: false,
+  },
   time: {
     type: Boolean,
     default: false,
   },
 });
 
-const date = ref();
+const qInputRef = useTemplateRef('inputRef')
+
 const treatedDate = defineModel();
 const untreatedDate = defineModel("untreatedDate");
+
 const activePanel = ref("date");
+const date = ref();
 
 const handleDateSelection = () => {
   if (time) {
@@ -98,6 +107,15 @@ const unformatDate = (value) => {
   return time && timePart ? `${formattedDate} ${timePart}` : formattedDate;
 };
 
+const inputMask = computed(() => {
+  if (!qInputRef.value) return '';
+
+  if (time) {
+    return masks.Brasil.datetime;
+  }
+  return masks.Brasil.date;
+});
+
 watch(date, (value) => {
   if (!value) return;
 

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

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác