소스 검색

feat: :sparkles: city, country, state

Denis 1 년 전
부모
커밋
fe7917ed24
56개의 변경된 파일4393개의 추가작업 그리고 1611개의 파일을 삭제
  1. 229 133
      package-lock.json
  2. 13 11
      package.json
  3. 0 27
      postcss.config.cjs
  4. 19 3
      quasar.config.js
  5. 144 0
      src/api/cacheService.js
  6. 38 0
      src/api/city.js
  7. 28 0
      src/api/country.js
  8. 33 0
      src/api/state.js
  9. 10 0
      src/api/user.js
  10. 16 10
      src/boot/axios.js
  11. 0 1
      src/boot/defaultPropsComponents.js
  12. 68 0
      src/boot/socket.io.js
  13. 0 47
      src/components/PasswordField.vue
  14. 90 0
      src/components/defaults/DefaultCepInput.vue
  15. 68 0
      src/components/defaults/DefaultCurrencyInput.vue
  16. 7 11
      src/components/defaults/DefaultDialogHeader.vue
  17. 221 0
      src/components/defaults/DefaultFilePicker.vue
  18. 134 0
      src/components/defaults/DefaultInputDatePicker.vue
  19. 28 0
      src/components/defaults/DefaultPasswordInput.vue
  20. 279 0
      src/components/defaults/DefaultTable.vue
  21. 373 0
      src/components/defaults/DefaultTableServerSide.vue
  22. 5 12
      src/components/defaults/DefaultTabs.vue
  23. 0 336
      src/components/geral/DefaultTable.vue
  24. 0 472
      src/components/geral/DefaultTableServerSide.vue
  25. 100 155
      src/components/layout/LeftMenuLayout.vue
  26. 52 104
      src/components/layout/LeftMenuLayoutMobile.vue
  27. 168 0
      src/components/regions/CitySelect.vue
  28. 97 0
      src/components/regions/CountrySelect.vue
  29. 177 0
      src/components/regions/StateSelect.vue
  30. 26 0
      src/composables/useFormUpdateTracker.js
  31. 18 9
      src/composables/useInputRules.js
  32. 59 0
      src/composables/useScroll.js
  33. 23 0
      src/css/app.scss
  34. 2 0
      src/helpers/masks.js
  35. 2 2
      src/helpers/utils.js
  36. 246 61
      src/i18n/locales/en.json
  37. 248 63
      src/i18n/locales/es.json
  38. 248 63
      src/i18n/locales/pt.json
  39. 3 3
      src/layouts/MainLayout.vue
  40. 1 1
      src/pages/ErrorNotFound.vue
  41. 5 18
      src/pages/LoginPage.vue
  42. 130 0
      src/pages/city/CityPage.vue
  43. 158 0
      src/pages/city/components/AddEditCityDialog.vue
  44. 125 0
      src/pages/country/CountryPage.vue
  45. 137 0
      src/pages/country/components/AddEditCountryDialog.vue
  46. 125 0
      src/pages/state/StatePage.vue
  47. 150 0
      src/pages/state/components/AddEditStateDialog.vue
  48. 23 20
      src/pages/users/UsersPage.vue
  49. 58 37
      src/pages/users/components/AddEditUserDialog.vue
  50. 45 0
      src/pages/users/components/UserTypeSelect.vue
  51. 1 1
      src/router/index.js
  52. 2 2
      src/router/routes.js
  53. 62 0
      src/router/routes/regions.route.js
  54. 4 6
      src/router/routes/users.route.js
  55. 90 0
      src/stores/navigation.js
  56. 5 3
      src/stores/permission.js

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 229 - 133
package-lock.json


+ 13 - 11
package.json

@@ -14,14 +14,16 @@
     "build": "quasar build"
   },
   "dependencies": {
-    "@quasar/extras": "^1.16.15",
-    "axios": "^1.7.9",
-    "chart.js": "^4.4.7",
-    "date-fns": "^3.6.0",
-    "pinia": "^2.3.0",
-    "quasar": "^2.17.4",
+    "@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",
+    "socket.io-client": "^4.8.1",
     "vue": "^3.5",
     "vue-chartjs": "^5.3.2",
+    "vue-currency-input": "^3.2.1",
     "vue-i18n": "^9.14.2",
     "vue-router": "^4.5.0"
   },
@@ -29,14 +31,14 @@
     "@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.0.1",
-    "autoprefixer": "^10.4.20",
+    "@quasar/app-vite": "^2.2.0",
+    "autoprefixer": "^10.4.21",
     "eslint": "^8.57.1",
     "eslint-config-prettier": "^9.1.0",
     "eslint-plugin-vue": "^9.32.0",
-    "postcss": "^8.4.49",
-    "prettier": "^3.4.2",
-    "vite-plugin-checker": "^0.6.4"
+    "postcss": "^8.5.3",
+    "prettier": "^3.5.3",
+    "vite-plugin-checker": "^0.9.1"
   },
   "engines": {
     "node": "^24 || ^22 || ^20 || ^18",

+ 0 - 27
postcss.config.cjs

@@ -1,27 +0,0 @@
-/* eslint-disable */
-// https://github.com/michael-ciniawsky/postcss-load-config
-
-module.exports = {
-  plugins: [
-    // https://github.com/postcss/autoprefixer
-    require('autoprefixer')({
-      overrideBrowserslist: [
-        'last 4 Chrome versions',
-        'last 4 Firefox versions',
-        'last 4 Edge versions',
-        'last 4 Safari versions',
-        'last 4 Android versions',
-        'last 4 ChromeAndroid versions',
-        'last 4 FirefoxAndroid versions',
-        'last 4 iOS versions'
-      ]
-    })
-
-    // https://github.com/elchininet/postcss-rtlcss
-    // If you want to support RTL css, then
-    // 1. yarn/npm install postcss-rtlcss
-    // 2. optionally set quasar.config.js > framework > lang to an RTL language
-    // 3. uncomment the following line:
-    // require('postcss-rtlcss')
-  ]
-}

+ 19 - 3
quasar.config.js

@@ -14,7 +14,13 @@ export default configure((ctx) => {
     // app boot file (/src/boot)
     // --> boot files are part of "main.js"
     // https://v2.quasar.dev/quasar-cli-vite/boot-files
-    boot: ["i18n", "axios", "setPermissions", "defaultPropsComponents"],
+    boot: [
+      "i18n",
+      "axios",
+      "setPermissions",
+      "defaultPropsComponents",
+      // "socket.io",
+    ],
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
     css: ["app.scss"],
@@ -26,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
@@ -50,8 +56,17 @@ export default configure((ctx) => {
       // publicPath: '/',
       // analyze: true,
       env: {
-        API_URL: ctx.dev ? "http://localhost:8000" : "https://api.example.com",
+        API_URL: ctx.dev
+          ? "http://localhost:8000"
+          : "http://localhost:8000",
         PASSWORD: ctx.dev ? "S@ft2080." : "",
+        WEBSOCKET_API: ctx.dev
+          ? "http://localhost:4321/"
+          : "http://localhost:4321/",
+        WEBSOCKET_PATH: ctx.dev ? "/socket.io" : "/socket.io",
+        WEBSOCKET_ROOM: ctx.dev ? "LARAVEL" : "LARAVEL",
+        WEBSOCKET_API_KEY:
+          "7wArC/kl0nTbt4zBu0agw.NXLyjA96I6x1XmBcuokwPqfo3/CIxzqYw.PTthh5eqa08Uf4ubFlOqatpShoz1CRRID9pZReEFvBk3il6E9u",
       },
       // rawDefine: {}
       // ignorePublicFolder: true,
@@ -99,6 +114,7 @@ export default configure((ctx) => {
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
     framework: {
+      lang: "pt-BR",
       config: {
         dark: "auto",
         notify: {

+ 144 - 0
src/api/cacheService.js

@@ -0,0 +1,144 @@
+import { api } from "src/boot/axios";
+
+const createDbConnection = (dbName = "apiCache", version = 1) => {
+  let db = null;
+  const ready = new Promise((resolve, reject) => {
+    const request = indexedDB.open(dbName, version);
+
+    request.onerror = () => reject(request.error);
+    request.onsuccess = () => {
+      db = request.result;
+      resolve(db);
+    };
+
+    request.onupgradeneeded = (event) => {
+      const db = event.target.result;
+      if (!db.objectStoreNames.contains("apiCache")) {
+        db.createObjectStore("apiCache", { keyPath: "key" });
+      }
+    };
+  });
+
+  return {
+    getDb: () => ready,
+  };
+};
+
+const { getDb } = createDbConnection();
+
+const getFromCache = async (key) => {
+  const db = await getDb();
+
+  return new Promise((resolve) => {
+    const transaction = db.transaction(["apiCache"], "readonly");
+    const store = transaction.objectStore("apiCache");
+    const request = store.get(key);
+
+    request.onsuccess = () => {
+      const entry = request.result;
+      if (entry?.timestamp + entry?.ttl > Date.now()) {
+        resolve(entry.data);
+      } else {
+        resolve(null);
+      }
+    };
+  });
+};
+
+const setInCache = async (key, data, ttl) => {
+  const db = await getDb();
+  const entry = {
+    key,
+    data,
+    timestamp: Date.now(),
+    ttl: ttl * 1000,
+  };
+
+  return new Promise((resolve) => {
+    const transaction = db.transaction(["apiCache"], "readwrite");
+    const store = transaction.objectStore("apiCache");
+    store.put(entry);
+    transaction.oncomplete = () => resolve();
+  });
+};
+
+const clearCache = async (cacheKey) => {
+  const db = await getDb();
+  const transaction = db.transaction(["apiCache"], "readwrite");
+  const store = transaction.objectStore("apiCache");
+
+  return new Promise((resolve) => {
+    if (!cacheKey) {
+      store.clear();
+      transaction.oncomplete = () => resolve();
+      return;
+    }
+
+    const request = store.getAllKeys();
+    request.onsuccess = () => {
+      const deletePromises = request.result
+        .filter((key) => key.includes(cacheKey))
+        .map(
+          (key) =>
+            new Promise((resolve) => {
+              const deleteRequest = store.delete(key);
+              deleteRequest.onsuccess = () => resolve();
+            }),
+        );
+
+      Promise.all(deletePromises).then(() => resolve());
+    };
+  });
+};
+
+export const createCachedApi = (namespace, ttl = 3600) => {
+  const getCacheKey = (path) => `${namespace}:${path}`;
+
+  const getResourcePaths = (path) => {
+    const segments = path.split("/").filter(Boolean);
+    const paths = [];
+
+    for (let i = 0; i <= segments.length; i++) {
+      const currentPath = "/" + segments.slice(0, i).join("/");
+      paths.push(currentPath);
+    }
+
+    return paths;
+  };
+
+  const invalidateAll = async (path) => {
+    const paths = getResourcePaths(path);
+    await Promise.all(paths.map((path) => clearCache(getCacheKey(path))));
+  };
+
+  const get = async (path, options = {}) => {
+    const key = getCacheKey(path);
+    const cached = await getFromCache(key);
+
+    if (cached) return cached;
+
+    const { data } = await api.get(path);
+    await setInCache(key, { data }, options.ttl ?? ttl);
+    return { data };
+  };
+
+  const post = async (path, payload) => {
+    const { data } = await api.post(path, payload);
+    await invalidateAll(path);
+    return { data };
+  };
+
+  const put = async (path, payload) => {
+    const { data } = await api.put(path, payload);
+    await invalidateAll(path);
+    return { data };
+  };
+
+  const del = async (path) => {
+    const { data } = await api.delete(path);
+    await invalidateAll(path);
+    return { data };
+  };
+
+  return { get, post, put, del, invalidateAll };
+};

+ 38 - 0
src/api/city.js

@@ -0,0 +1,38 @@
+import { createCachedApi } from "./cacheService";
+
+const api = createCachedApi("city");
+
+export const getCity = async (id) => {
+  const { data } = await api.get("/city/" + id);
+  return data.payload;
+};
+
+export const getCities = async () => {
+  const { data } = await api.get("/city");
+  return data.payload;
+};
+
+export const getCityByState = async (stateId) => {
+  const { data } = await api.get(`/city-state/${stateId}`);
+  return data.payload;
+};
+
+export const getCityByCountry = async (countryId) => {
+  const { data } = await api.get(`/city-country/${countryId}`);
+  return data.payload;
+};
+
+export const createCity = async (city) => {
+  const { data } = await api.post("/city", city);
+  return data.payload;
+};
+
+export const updateCity = async (city, id) => {
+  const { data } = await api.put(`/city/${id}`, city);
+  return data.payload;
+};
+
+export const deleteCity = async (id) => {
+  const { data } = await api.del(`/city/${id}`);
+  return data.payload;
+};

+ 28 - 0
src/api/country.js

@@ -0,0 +1,28 @@
+import { createCachedApi } from "./cacheService";
+
+const api = createCachedApi("country");
+
+export const getCountry = async (id) => {
+  const { data } = await api.get("/country/" + id);
+  return data.payload;
+};
+
+export const getCountries = async () => {
+  const { data } = await api.get("/country");
+  return data.payload;
+};
+
+export const createCountry = async (country) => {
+  const data = await api.post("/country", country);
+  return data.payload;
+};
+
+export const updateCountry = async (country, id) => {
+  const data = await api.put(`/country/${id}`, country);
+  return data.payload;
+};
+
+export const deleteCountry = async (id) => {
+  const data = await api.del(`/country/${id}`);
+  return data.payload;
+};

+ 33 - 0
src/api/state.js

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

+ 10 - 0
src/api/user.js

@@ -19,3 +19,13 @@ export const updateUser = async (user, id) => {
   const { data } = await api.put(`/user/${id}`, user);
   return data.payload;
 };
+
+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;
+}

+ 16 - 10
src/boot/axios.js

@@ -29,7 +29,7 @@ let isRefreshing = false;
 let validQueue = [];
 
 const errorInterceptor = async (error) => {
-  if (!error.config.retryCount) {
+  if (error.config?.retryCount) {
     error.config.retryCount = 0;
   }
 
@@ -63,11 +63,19 @@ const errorInterceptor = async (error) => {
     }
 
     isRefreshing = true;
-
     try {
       await useAuth().refreshToken();
-      isRefreshing = false;
+    } catch (error) {
+      validQueue = [];
+      Cookies.remove("access_token");
+      Cookies.remove("refresh_token");
+      if (window.location.pathname !== "/login") {
+        window.location.href = "/login";
+      }
+      return Promise.reject(error);
+    }
 
+    try {
       validQueue.forEach((request) => {
         request.resolve(api.request(request.config));
       });
@@ -75,14 +83,12 @@ const errorInterceptor = async (error) => {
 
       return await api.request(error.config);
     } catch (error) {
+      Notify.create({
+        message: error.response.data.message,
+        type: "negative",
+      });
+    } finally {
       isRefreshing = false;
-      validQueue = [];
-      Cookies.remove("access_token");
-      Cookies.remove("refresh_token");
-      if (window.location.pathname !== "/login") {
-        window.location.href = "/login";
-      }
-      return Promise.reject(error);
     }
   }
 

+ 0 - 1
src/boot/defaultPropsComponents.js

@@ -27,7 +27,6 @@ export default boot(() => {
   });
   SetComponentDefaults(QBtn, {
     outline: true,
-    padding: "10px 16px"
   });
   SetComponentDefaults(QCard, {
     flat: true,

+ 68 - 0
src/boot/socket.io.js

@@ -0,0 +1,68 @@
+import { boot } from "quasar/wrappers";
+import { io } from "socket.io-client";
+
+const socket = io(process.env.WEBSOCKET_API, {
+  transport: ["websocket"],
+  path: process.env.WEBSOCKET_PATH,
+  auth: {
+    apiKey: process.env.WEBSOCKET_API_KEY,
+  },
+  reconnection: true,
+  reconnectionDelay: 1000,
+  timeout: 20000,
+});
+
+const joinRoom = (roomName) => {
+  socket.emit("join", process.env.WEBSOCKET_ROOM + ":" + roomName);
+};
+
+const leaveRoom = (roomName) => {
+  socket.emit("leave", process.env.WEBSOCKET_ROOM + ":" + roomName);
+};
+
+const sendEventToLaravel = (eventName, data) => {
+  const channel = process.env.WEBSOCKET_ROOM + ":" + eventName;
+  socket.emit("eventWrapperToLaravel", {
+    channel: channel,
+    data: data,
+  });
+};
+
+const sendEvent = (room, eventName, data) => {
+  const channel = process.env.WEBSOCKET_ROOM + ":" + room + "@" + eventName;
+  socket.emit("eventWrapperToNode", {
+    channel: channel,
+    data: data,
+  });
+};
+
+export default boot(async () => {
+  socket.on("connect", () => {
+    console.log("Connected to websocket server!");
+  });
+
+  socket.on("disconnect", () => {
+    console.log("Disconnected from websocket server!");
+  });
+
+  socket.on("connect_error", (error) => {
+    console.error("Websocket connection error: ", error);
+  });
+
+  socket.on("connect_timeout", (timeout) => {
+    console.error("Websocket connection timeout: ", timeout);
+  });
+
+  socket.on("reconnect", (attemptNumber) => {
+    console.log(
+      "Reconnected to websocket server! Attempt number: ",
+      attemptNumber,
+    );
+  });
+
+  socket.on("reconnect_attempt", (attemptNumber) => {
+    console.log("Reconnect attempt number: ", attemptNumber);
+  });
+});
+
+export { socket, joinRoom, leaveRoom, sendEvent, sendEventToLaravel };

+ 0 - 47
src/components/PasswordField.vue

@@ -1,47 +0,0 @@
-<template>
-  <!-- Password -->
-  <q-input
-    v-model="form.password"
-    class="col-6"
-    filled
-    :label="$t('users.password')"
-    :rules="[inputRules.required]"
-    hide-bottom-space
-    :type="isPwd ? 'password' : 'text'"
-  >
-    <template #append>
-      <q-icon
-        :name="isPwd ? 'mdi-eye-off' : 'mdi-eye'"
-        class="cursor-pointer"
-        @click="isPwd = !isPwd"
-      />
-    </template>
-  </q-input>
-
-  <!-- Password Confirmation -->
-  <q-input
-    v-model="form.password_confirmation"
-    class="col-6"
-    filled
-    :label="$t('general.confirm_password')"
-    :rules="[inputRules.required, confirmedPassword]"
-    hide-bottom-space
-    lazy-rules
-    :type="isPwd ? 'password' : 'text'"
-  >
-    <template #append>
-      <q-icon
-        :name="isPwd ? 'mdi-eye-off' : 'mdi-eye'"
-        class="cursor-pointer"
-        @click="isPwd = !isPwd"
-      />
-    </template>
-  </q-input>
-</template>
-
-<script setup>
-import { ref } from "vue";
-import { useInputRules } from "src/composables/useInputRules";
-const { inputRules } = useInputRules();
-const isPwd = ref(true);
-</script>

+ 90 - 0
src/components/defaults/DefaultCepInput.vue

@@ -0,0 +1,90 @@
+<template>
+  <div v-bind="$attrs">
+    <q-input
+      v-model="model"
+      outlined
+      debounce="500"
+      :disable="disable"
+      :class="disable ?? 'no-pointer-events'"
+      :readonly="readonly"
+      :label="newLabel"
+      :mask="masks.Brasil.cep"
+      :rules="rules"
+      :loading="loading"
+    />
+  </div>
+</template>
+<script setup>
+import { watch, computed, ref, nextTick } from "vue";
+import { useQuasar } from "quasar";
+import masks from "src/helpers/masks.js";
+
+const $q = useQuasar();
+
+const model = defineModel();
+
+const { disable, readonly, label, rules } = defineProps({
+  disable: {
+    type: Boolean,
+    default: false,
+  },
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
+  label: {
+    type: String,
+    default: "CEP",
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const emit = defineEmits([
+  "cidade",
+  "estado",
+  "rua",
+  "bairro",
+  "numero",
+  "complemento",
+  "uf",
+]);
+
+const loading = ref(false);
+
+const newLabel = computed(() => label ?? void 0);
+
+watch(
+  () => model.value,
+  async (value) => {
+    if (value < 0) {
+      model.value = 0;
+    }
+    if (value && value.length > 8) {
+      try {
+        loading.value = true;
+        const response = await fetch(`https://viacep.com.br/ws/${value}/json`);
+        const data = await response.json();
+        emit("estado", data.estado);
+        emit("uf", data.uf);
+        // this is a hack to work well with the city and state select
+        await nextTick();
+        emit("cidade", data.localidade);
+        emit("rua", data.logradouro);
+        emit("bairro", data.bairro);
+        emit("numero", null);
+        emit("complemento", null);
+      } catch (error) {
+        $q.notify({
+          message: "CEP inválido",
+          color: "negative",
+        });
+      } finally {
+        loading.value = false;
+      }
+    }
+  },
+);
+</script>

+ 68 - 0
src/components/defaults/DefaultCurrencyInput.vue

@@ -0,0 +1,68 @@
+<template>
+  <q-input
+    ref="inputRef"
+    v-model="formattedValue"
+    outlined
+    :error-message="errorMessage"
+    :error="!!errorMessage"
+    :disable="disable"
+    :class="disable ? 'no-pointer-events' : ''"
+    :label="newLabel"
+    :readonly="readonly"
+  >
+  </q-input>
+</template>
+<script setup>
+import { useCurrencyInput } from "vue-currency-input";
+import { computed, watch } from "vue";
+import { useI18n } from "vue-i18n";
+
+const model = defineModel();
+
+const { options, disable, readonly, label } = defineProps({
+  options: {
+    type: Object,
+    default: () => ({
+      locale: "pt-BR",
+      currency: "BRL",
+      currencyDisplay: "symbol",
+      hideCurrencySymbolOnFocus: false,
+      hideGroupingSeparatorOnFocus: false,
+      hideNegligibleDecimalDigitsOnFocus: false,
+      autoDecimalDigits: true,
+      useGrouping: true,
+      accountingSign: false,
+    }),
+  },
+  disable: {
+    type: Boolean,
+    default: false,
+  },
+  readonly: {
+    type: Boolean,
+    default: false,
+  },
+  label: {
+    type: String,
+    default: () => useI18n().t("common.terms.currency"),
+  },
+});
+
+const { inputRef, formattedValue, numberValue, setValue } =
+  useCurrencyInput(options);
+
+const errorMessage = computed(() =>
+  numberValue.value < 0
+    ? useI18n().t("validation.rules.value_smaller_than_zero")
+    : undefined,
+);
+
+const newLabel = computed(() => (label ? label : void 0));
+
+watch(
+  () => model.value,
+  (value) => {
+    setValue(value);
+  },
+);
+</script>

+ 7 - 11
src/components/geral/DefaultDialogHeader.vue → src/components/defaults/DefaultDialogHeader.vue

@@ -1,16 +1,12 @@
 <template>
-  <q-bar
-    class="q-py-md"
-    v-bind="$attrs"
-    style="height: 50px"
-  >
-    <q-icon v-if="props.icon" :name="props.icon" />
-    <div>{{ props.title() }}</div>
+  <q-bar class="q-py-md" v-bind="$attrs" style="height: 50px">
+    <q-icon v-if="icon" :name="icon" />
+    <div>{{ title() }}</div>
 
     <q-space />
 
     <q-btn
-      v-if="props.maximizable"
+      v-if="maximizable"
       dense
       flat
       :icon="!maximizedToggle ? 'mdi-arrow-expand' : 'mdi-arrow-collapse'"
@@ -27,10 +23,10 @@ import { useI18n } from "vue-i18n";
 
 const emit = defineEmits(["maximized", "close"]);
 
-const props = defineProps({
+const { title, fullscreen, maximizable, icon } = defineProps({
   title: {
     type: Function,
-    default: () => useI18n().t("general.title"),
+    default: () => useI18n().t("common.terms.title"),
   },
   fullscreen: {
     type: Boolean,
@@ -55,7 +51,7 @@ const onMaximazedClick = () => {
 };
 
 onMounted(() => {
-  if (props.fullscreen) {
+  if (fullscreen) {
     maximizedToggle.value = true;
   }
 });

+ 221 - 0
src/components/defaults/DefaultFilePicker.vue

@@ -0,0 +1,221 @@
+<template>
+  <q-field v-model="model" v-bind="$attrs" borderless :rules="rules">
+    <div class="column flex-center q-mb-sm full-width">
+      <span class="text-grey-6">{{ label }}</span>
+      <div
+        class="image-preview-container"
+        :class="{
+          'has-image': preview,
+          'is-dragging': isDragging,
+        }"
+        @click="pickImage"
+        @dragover.prevent="handleDragOver"
+        @dragleave.prevent="handleDragLeave"
+        @drop.prevent="handleDrop"
+      >
+        <template v-if="!preview">
+          <q-icon
+            :name="
+              isDragging
+                ? 'file_upload'
+                : type == 'image'
+                  ? 'add_photo_alternate'
+                  : 'insert_drive_file'
+            "
+            size="48px"
+            color="grey-6"
+            class="absolute-center"
+          />
+          <div
+            class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
+          >
+            {{
+              isDragging
+                ? $t("common.ui.file.drag_and_drop")
+                : type == "image"
+                  ? $t("common.ui.file.click_select_image")
+                  : $t("common.ui.file.click_select")
+            }}
+          </div>
+        </template>
+        <q-img
+          v-else-if="type == 'image'"
+          :src="preview"
+          fit="cover"
+          class="full-height"
+        >
+          <div class="absolute-bottom text-right">
+            <q-btn
+              flat
+              dense
+              round
+              color="negative"
+              icon="delete"
+              @click.stop="clearImage"
+            />
+          </div>
+        </q-img>
+        <div v-else class="position-relative column full-height flex-center">
+          <q-icon name="mdi-file-check" size="48px" color="grey-6" />
+          <div
+            class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
+          >
+            {{ preview }}
+          </div>
+        </div>
+      </div>
+
+      <q-file v-show="false" ref="fileInput" v-model="model" :accept="accept" />
+    </div>
+  </q-field>
+</template>
+
+<script setup>
+import { ref, useTemplateRef, watch } from "vue";
+
+const { label, rules, accept, type, initialImage } = defineProps({
+  label: {
+    type: String,
+    default: "Select Image",
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  accept: {
+    type: String,
+    default: "image/*",
+  },
+  type: {
+    type: String,
+    default: "image",
+  },
+  initialImage: {
+    type: String,
+    default: null,
+  },
+});
+
+const model = defineModel();
+const base64File = defineModel("base64File", { type: String, default: null });
+const preview = ref(initialImage);
+const isDragging = ref(false);
+const fileInput = useTemplateRef("fileInput");
+
+const processFile = async (file) => {
+  if (!file) {
+    console.error("No file provided");
+    return;
+  }
+
+  if (type == "image" && !file.type.startsWith("image/")) {
+    console.error("Invalid file type");
+    return;
+  }
+
+  if (type == "file") {
+    const blob = new Blob([file], { type: file.type });
+    preview.value = file.name;
+    return new Promise((resolve) => {
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        base64File.value = e.target.result;
+        console.log(preview.value);
+        resolve();
+      };
+      reader.readAsDataURL(blob);
+    });
+  } else {
+    return new Promise((resolve) => {
+      const reader = new FileReader();
+      reader.onload = (e) => {
+        base64File.value = e.target.result;
+        preview.value = e.target.result;
+        resolve();
+      };
+      reader.readAsDataURL(file);
+    });
+  }
+};
+
+const pickImage = () => {
+  fileInput.value?.pickFiles();
+};
+
+const clearImage = () => {
+  model.value = null;
+  preview.value = null;
+};
+
+const handleDragOver = () => {
+  isDragging.value = true;
+};
+
+const handleDragLeave = () => {
+  isDragging.value = false;
+};
+
+const handleDrop = async (event) => {
+  isDragging.value = false;
+  const file = event.dataTransfer?.files?.[0];
+
+  if (file) {
+    model.value = file;
+    await processFile(file);
+  }
+};
+
+watch(
+  () => model.value,
+  async (value, oldValue) => {
+    if (value != oldValue) {
+      await processFile(value);
+    } else {
+      preview.value = null;
+    }
+  },
+);
+</script>
+
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss";
+
+.image-preview-container {
+  .body--dark & {
+    --image-bg-color: #{map.get($colors-dark, "surface")};
+    --image-border-color: #{map.get($colors-dark, "primary")};
+    --image-border-hover-color: #{map.get($colors-dark, "primary-dark")};
+  }
+
+  .body--light & {
+    --image-bg-color: #{map.get($colors, "surface")};
+    --image-border-color: #{map.get($colors, "primary")};
+    --image-border-hover-color: #{map.get($colors, "primary-dark")};
+  }
+
+  width: 200px;
+  height: 200px;
+  border: 2px dashed var(--image-border-color);
+  border-radius: 4px;
+  position: relative;
+  overflow: hidden;
+  transition: all 0.3s;
+  cursor: pointer;
+
+  &.is-dragging {
+    border-color: var(--image-border-hover-color);
+    background-color: var(--image-bg-color);
+    opacity: 0.8;
+  }
+
+  &:hover {
+    border-color: var(--image-border-hover-color);
+    background-color: var(--image-bg-color);
+  }
+
+  &.has-image {
+    border-style: solid;
+  }
+}
+</style>

+ 134 - 0
src/components/defaults/DefaultInputDatePicker.vue

@@ -0,0 +1,134 @@
+<template>
+  <div v-bind="$attrs">
+    <q-input
+      v-model="treatedDate"
+      :mask="time ? masks.Brasil.datetime : masks.Brasil.date"
+      :label="label"
+      :rules="rules"
+      clearable
+    >
+      <template #append>
+        <q-icon
+          :name="time ? 'mdi-calendar-clock' : 'mdi-calendar'"
+          class="cursor-pointer"
+        >
+          <q-popup-proxy cover transition-show="scale" transition-hide="scale">
+            <template v-if="!time">
+              <q-date v-model="date" mask="YYYY-MM-DD">
+                <div class="row items-center justify-end">
+                  <q-btn v-close-popup label="Close" color="primary" flat />
+                </div>
+              </q-date>
+            </template>
+
+            <template v-else>
+              <q-tab-panels
+                v-model="activePanel"
+                animated
+                transition-prev="slide-right"
+                transition-next="slide-left"
+                class="bg-white"
+              >
+                <q-tab-panel name="date" class="q-pa-none">
+                  <q-date
+                    v-model="date"
+                    mask="YYYY-MM-DD HH:mm"
+                    @update:model-value="handleDateSelection"
+                  />
+                </q-tab-panel>
+
+                <q-tab-panel name="time" class="q-pa-none">
+                  <q-time v-model="date" mask="YYYY-MM-DD HH:mm" format24h />
+                </q-tab-panel>
+              </q-tab-panels>
+            </template>
+          </q-popup-proxy>
+        </q-icon>
+      </template>
+    </q-input>
+  </div>
+</template>
+
+<script setup>
+import { watch, ref } from "vue";
+import { useI18n } from "vue-i18n";
+import masks from "src/helpers/masks";
+
+const { label, rules, time } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("common.terms.date"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  time: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const date = ref();
+const treatedDate = defineModel();
+const untreatedDate = defineModel("untreatedDate");
+const activePanel = ref("date");
+
+const handleDateSelection = () => {
+  if (time) {
+    activePanel.value = "time";
+  }
+};
+
+const formatDate = (value) => {
+  if (!value) return null;
+
+  const [datePart, timePart] = value.split(" ");
+  const formattedDate = datePart.split("-").reverse().join("/");
+
+  return time && timePart ? `${formattedDate} ${timePart}` : formattedDate;
+};
+
+const unformatDate = (value) => {
+  if (!value) return null;
+
+  const [datePart, timePart] = value.split(" ");
+  const formattedDate = datePart.split("/").reverse().join("-");
+
+  return time && timePart ? `${formattedDate} ${timePart}` : formattedDate;
+};
+
+watch(date, (value) => {
+  if (!value) return;
+
+  untreatedDate.value = value;
+  treatedDate.value = formatDate(value);
+});
+
+watch(treatedDate, (value) => {
+  if (!value) {
+    date.value = null;
+    untreatedDate.value = null;
+    treatedDate.value = null;
+    activePanel.value = "date";
+    return;
+  }
+  date.value = unformatDate(value);
+});
+
+watch(
+  untreatedDate,
+  (value) => {
+    if (!value) {
+      date.value = null;
+      untreatedDate.value = null;
+      treatedDate.value = null;
+      activePanel.value = "date";
+      return;
+    }
+    date.value = value;
+    treatedDate.value = formatDate(value);
+  },
+  { immediate: true },
+);
+</script>

+ 28 - 0
src/components/defaults/DefaultPasswordInput.vue

@@ -0,0 +1,28 @@
+<template>
+  <q-input
+    v-model="password"
+    v-bind="$attrs"
+    :label="$t('common.terms.password')"
+    :type="!seePassword ? 'password' : 'text'"
+    :rules="rules"
+  >
+    <template #append>
+      <q-icon
+        :name="seePassword ? 'mdi-eye-off' : 'mdi-eye'"
+        class="cursor-pointer q-ml-md"
+        @click="seePassword = !seePassword"
+      />
+    </template>
+  </q-input>
+</template>
+<script setup>
+const { rules } = defineProps({
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const password = defineModel();
+const seePassword = defineModel("seePassword", { default: false });
+</script>

+ 279 - 0
src/components/defaults/DefaultTable.vue

@@ -0,0 +1,279 @@
+<template>
+  <q-table
+    v-model:fullscreen="fullscreen"
+    flat
+    :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"
+    class="softpar-table q-pa-sm"
+    @row-click="onRowClick"
+  >
+    <template #top>
+      <div
+        class="flex full-width justify-between align-center q-mb-md q-pl-sm"
+        style="gap: 1rem"
+      >
+        <q-input
+          v-if="showSearchField"
+          v-model="filter"
+          debounce="250"
+          :placeholder="$t('common.actions.search')"
+          clearable
+          autofocus
+          class=""
+          color="primary"
+        >
+          <template #append>
+            <q-icon name="mdi-magnify" />
+          </template>
+        </q-input>
+
+        <q-select
+          v-if="showColumnsSelect"
+          v-model="visibleColumns"
+          multiple
+          options-outlined
+          :display-value="$q.lang.table.columns"
+          emit-value
+          map-options
+          :options="mapColumns"
+          style="width: 150px"
+          options-selected-class="text-bold"
+        />
+
+        <q-space />
+
+        <q-btn
+          v-if="addItem"
+          color="primary"
+          padding="10px 16px"
+          :outline="outlineAdd"
+          :label="$t('common.actions.add')"
+          @click="onAddItem"
+        >
+        </q-btn>
+      </div>
+    </template>
+
+    <template #body-cell-actions="{ row }">
+      <q-td v-if="deleteFunction">
+        <q-item-section>
+          <q-btn
+            color="negative"
+            flat
+            dense
+            icon="mdi-delete"
+            style="width: 45px"
+            class="q-ml-auto q-mr-sm"
+            @click.prevent.stop="onDelete(row.id)"
+          />
+        </q-item-section>
+      </q-td>
+    </template>
+
+    <template #loading>
+      <q-inner-loading showing color="primary" />
+    </template>
+
+    <template v-if="!hideNoDataLabel" #no-data>
+      <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>
+    </template>
+
+    <template v-for="name in $slots" #[name]="data">
+      <slot :name="name" v-bind="data"></slot>
+    </template>
+  </q-table>
+</template>
+
+<script setup>
+import { ref, onMounted, toRaw, watch } from "vue";
+import { useRouter } from "vue-router";
+
+const emit = defineEmits(["onRowClick", "onAddItem", "noRows"]);
+
+const {
+  columns,
+  apiCall,
+  outlineAdd,
+  openItem,
+  openItemRoute,
+  addItem,
+  addItemRoute,
+  rowsPerPage,
+  showSearchField,
+  noApiCall,
+  hideNoDataLabel,
+  deleteFunction,
+} = defineProps({
+  columns: {
+    type: Array,
+    required: true,
+  },
+  apiCall: {
+    type: Function,
+    required: true,
+  },
+  outlineAdd: {
+    type: Boolean,
+    default: false,
+  },
+  openItem: {
+    type: Boolean,
+    default: false,
+  },
+  openItemRoute: {
+    type: String,
+    default: "",
+  },
+  addItem: {
+    type: Boolean,
+    default: true,
+  },
+  addItemRoute: {
+    type: String,
+    default: "",
+  },
+  rowsPerPage: {
+    type: Number,
+    default: 10,
+  },
+  showSearchField: {
+    type: Boolean,
+    default: true,
+  },
+  showColumnsSelect: {
+    type: Boolean,
+    default: true,
+  },
+  noApiCall: {
+    type: Boolean,
+    default: false,
+  },
+  hideNoDataLabel: {
+    type: Boolean,
+    default: false,
+  },
+  deleteFunction: {
+    type: Function,
+    default: null,
+  },
+});
+
+const router = useRouter();
+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 () => {
+    await onRequest();
+  },
+);
+
+const mapColumns = columns.reduce((accm, column) => {
+  if (!column.required) {
+    accm.push({
+      label: column.label,
+      value: column.name,
+    });
+  }
+  return accm;
+}, []);
+
+const visibleColumns = ref(mapColumns.map((column) => column.value));
+
+const onRowClick = (evt, row, index) => {
+  const item = toRaw(row);
+  if (openItem) {
+    if (openItemRoute) {
+      router.push({ name: openItemRoute, params: { id: item.id } });
+    } else {
+      emit("onRowClick", { evt, row, index });
+    }
+  }
+};
+
+const onAddItem = () => {
+  if (addItem) {
+    if (addItemRoute) {
+      router.push({ name: addItemRoute });
+    } else {
+      emit("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;
+    }
+  }
+};
+
+const onRequest = async () => {
+  if (noApiCall) {
+    loading.value = false;
+    return;
+  }
+  loading.value = true;
+
+  const response = await apiCall();
+  rows.value.splice(0, rows.value.length, ...response);
+
+  if (rows.value.length == 0) {
+    emit("noRows");
+  }
+  loading.value = false;
+};
+
+onMounted(async () => {
+  await onRequest({
+    filter: undefined,
+  });
+});
+
+defineExpose({
+  refresh: onRequest,
+});
+</script>
+
+<style lang="scss">
+@import "src/css/table.scss";
+</style>

+ 373 - 0
src/components/defaults/DefaultTableServerSide.vue

@@ -0,0 +1,373 @@
+# DefaultTableServerSide.vue
+<template>
+  <q-table
+    v-model:fullscreen="fullscreen"
+    v-model:pagination="pagination"
+    row-key="id"
+    flat
+    class="softpar-table q-pa-sm"
+    :pagination-label="getPaginationLabel"
+    :rows="rows"
+    :rows-per-page-label="$t('common.ui.table.rows_per_page')"
+    :columns="columns"
+    :visible-columns="visibleColumns"
+    :filter="pagination.filter"
+    :grid="$q.screen.lt.sm"
+    :loading="loading"
+    v-bind="$attrs"
+    @row-click="onRowClick"
+  >
+    <template #top>
+      <div
+        class="flex full-width justify-between items-center q-mb-md q-pl-sm"
+        style="gap: 1rem"
+      >
+        <q-input
+          v-if="showSearchField"
+          v-model="pagination.filter"
+          outlined
+          dense
+          debounce="500"
+          :placeholder="$t('common.actions.search')"
+          clearable
+          autofocus
+        >
+          <template #append>
+            <q-icon name="mdi-magnify" />
+          </template>
+        </q-input>
+
+        <q-select
+          v-if="showColumnsSelect"
+          v-model="visibleColumns"
+          class="q-ml-md"
+          multiple
+          dense
+          outlined
+          options-outlined
+          :display-value="$q.lang.table.columns"
+          emit-value
+          map-options
+          :options="mapColumns"
+          style="min-width: 150px"
+          options-selected-class="text-bold"
+        />
+
+        <q-space />
+
+        <q-btn
+          v-if="addItem"
+          color="primary"
+          padding="12px 16px"
+          :outline="outlineAdd"
+          :label="labelAdd"
+          @click="onAddItem"
+        />
+      </div>
+    </template>
+
+    <template #body-cell-actions="{ row }">
+      <q-td v-if="deleteFunction">
+        <q-item-section>
+          <q-btn
+            color="negative"
+            flat
+            dense
+            icon="mdi-delete"
+            style="width: 45px"
+            class="q-ml-auto q-mr-sm"
+            @click.prevent.stop="onDelete(row.id)"
+          />
+        </q-item-section>
+      </q-td>
+    </template>
+
+    <template v-if="!hideNoDataLabel" #no-data>
+      <div class="q-my-md row justify-center full-width">
+        <q-spinner v-if="loading" color="primary" size="30px" />
+        <div v-else class="q-pa-md body2">
+          {{ $t("http.errors.no_records_found") }}
+        </div>
+      </div>
+    </template>
+
+    <template #bottom="scope">
+      <div class="flex full-width justify-end">
+        <div class="flex items-center">
+          {{ $t("common.ui.table.rows_per_page") }}
+          <q-select
+            v-model="pagination.rowsPerPage"
+            class="q-mx-sm"
+            dense
+            borderless
+            :options="rowsPerPageOptions"
+          >
+            <template #option="selectData">
+              <q-item v-bind="selectData.itemProps">
+                <q-item-section>
+                  <q-item-label>{{
+                    selectData.opt == 0 ? $t("common.ui.misc.all") : selectData.opt
+                  }}</q-item-label>
+                </q-item-section>
+              </q-item>
+            </template>
+          </q-select>
+        </div>
+        <div class="flex items-center">
+          {{ pagination.from + "-" + pagination.to }} {{ $t("common.ui.table.of") }}
+          {{ pagination.rowsNumber }}
+        </div>
+        <div class="flex items-center">
+          <q-btn
+            icon="mdi-chevron-left"
+            color="grey-8"
+            round
+            dense
+            flat
+            :disable="scope.isFirstPage"
+            @click="prevPage"
+          />
+          <q-btn
+            icon="mdi-chevron-right"
+            color="grey-8"
+            round
+            dense
+            flat
+            :disable="scope.isLastPage"
+            @click="nextPage"
+          />
+        </div>
+      </div>
+    </template>
+
+    <template v-for="name in $slots" #[name]="data">
+      <slot :name="name" v-bind="data"></slot>
+    </template>
+  </q-table>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, toRaw, watch } from "vue";
+import { useI18n } from "vue-i18n";
+import { useRouter } from "vue-router";
+
+const emit = defineEmits([
+  "onRowClick",
+  "onAddItem",
+  "noRows",
+  "togglePrincipal",
+]);
+
+const {
+  columns,
+  apiCall,
+  outlineAdd,
+  openItem,
+  openItemRoute,
+  addItem,
+  addItemRoute,
+  rowsPerPage,
+  showSearchField,
+  hideNoDataLabel,
+  deleteFunction,
+} = defineProps({
+  columns: {
+    type: Array,
+    required: true,
+  },
+  apiCall: {
+    type: Function,
+    required: true,
+  },
+  labelAdd: {
+    type: String,
+    default: "Adicionar",
+  },
+  outlineAdd: {
+    type: Boolean,
+    default: false,
+  },
+  openItem: {
+    type: Boolean,
+    default: false,
+  },
+  openItemRoute: {
+    type: String,
+    default: "",
+  },
+  addItem: {
+    type: Boolean,
+    default: true,
+  },
+  addItemRoute: {
+    type: String,
+    default: "",
+  },
+  rowsPerPage: {
+    type: Number,
+    default: 10,
+  },
+  showColumnsSelect: {
+    type: Boolean,
+    default: false,
+  },
+  showSearchField: {
+    type: Boolean,
+    default: true,
+  },
+  noApiRoute: {
+    type: Boolean,
+    default: false,
+  },
+  hideNoDataLabel: {
+    type: Boolean,
+    default: false,
+  },
+  deleteFunction: {
+    type: Function,
+    default: null,
+  },
+});
+
+const { t } = useI18n();
+const router = useRouter();
+const rows = ref([]);
+const loading = ref(true);
+const fullscreen = ref(false);
+const rowsPerPageOptions = [10, 15, 25, 50];
+
+const pagination = ref({
+  filter: undefined,
+  page: 1,
+  rowsPerPage: rowsPerPage,
+  rowsNumber: 0,
+  from: 0,
+  to: 0,
+});
+
+const mapColumns = computed(() => {
+  return columns.reduce((accm, column) => {
+    if (!column.required) {
+      accm.push({
+        label: column.label.toUpperCase(),
+        value: column.name,
+      });
+    }
+    return accm;
+  }, []);
+});
+
+const visibleColumns = ref(mapColumns.value.map((column) => column.value));
+
+const onRowClick = (evt, row, index) => {
+  const item = toRaw(row);
+  if (openItem) {
+    if (openItemRoute) {
+      router.push({ name: openItemRoute, params: { id: item.id } });
+    } else {
+      emit("onRowClick", { evt, row, index });
+    }
+  }
+};
+
+const onAddItem = () => {
+  if (addItem) {
+    if (addItemRoute) {
+      router.push({ name: addItemRoute });
+    } else {
+      emit("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;
+    }
+  }
+};
+
+const prevPage = () => {
+  pagination.value.page--;
+  onRequest();
+};
+
+const nextPage = () => {
+  pagination.value.page++;
+  onRequest();
+};
+
+const getPaginationLabel = (from, to, total) => {
+  return `${from}-${to} ${t?.("common.ui.table.of") ?? "of"} ${total}`;
+};
+
+let isFetching = false;
+const onRequest = async () => {
+  if (isFetching) return;
+
+  isFetching = true;
+  loading.value = true;
+
+  try {
+    const response = await apiCall({
+      page: pagination.value.page,
+      perPage: pagination.value.rowsPerPage,
+      filter: pagination.value.filter,
+    });
+
+    rows.value = response.data.result.data;
+    pagination.value.rowsNumber = response.data.result.total;
+    pagination.value.from = response.data.result.from;
+    pagination.value.to = response.data.result.to;
+
+    if (rows.value.length === 0) {
+      emit("noRows");
+    }
+  } catch (error) {
+    console.error("Error fetching data:", error);
+  } finally {
+    loading.value = false;
+    isFetching = false;
+  }
+};
+
+watch(
+  () => apiCall,
+  () => onRequest(),
+);
+
+watch(
+  pagination,
+  async (newVal, oldVal) => {
+    if (!oldVal || loading.value) return;
+
+    if (
+      newVal.rowsPerPage !== oldVal.rowsPerPage ||
+      newVal.filter !== oldVal.filter ||
+      newVal.page !== oldVal.page
+    ) {
+      await onRequest();
+    }
+  },
+  { deep: true },
+);
+
+onMounted(async () => {
+  await onRequest();
+});
+
+defineExpose({
+  refresh: onRequest,
+});
+</script>
+
+<style lang="scss">
+@import "src/css/table.scss";
+</style>

+ 5 - 12
src/components/geral/TabsGlobal.vue → src/components/defaults/DefaultTabs.vue

@@ -1,39 +1,32 @@
 <template>
   <q-tabs
+    v-model="tab"
     class="button bg-background-2 text-font"
     indicator-color="transparent"
     active-color="primary"
-    :model-value="tab"
     v-bind="$attrs"
     align="justify"
     active-bg-color="white"
   >
     <q-tab
-      v-for="(q_tab, i) in props.tabsItems"
+      v-for="(q_tab, i) in tabsItems"
       :key="i"
       :name="q_tab.name"
       :label="q_tab.label"
       :disable="q_tab.disable"
       :class="{ hidden: q_tab.hide }"
-      @update:model-value="(value) => $emit('update:tab', value)"
     />
   </q-tabs>
 </template>
 
 <script setup>
-defineEmits(["update:tab"]);
-
-const props = defineProps({
+const { tabsItems } = defineProps({
   tabsItems: {
     type: Array,
     required: false,
     default: () => [],
   },
-
-  tab: {
-    type: String,
-    required: false,
-    default: "",
-  },
 });
+
+const tab = defineModel();
 </script>

+ 0 - 336
src/components/geral/DefaultTable.vue

@@ -1,336 +0,0 @@
-<template>
-  <q-table
-    v-model:fullscreen="fullscreen"
-    flat
-    :pagination="{ rowsPerPage }"
-    :pagination-label="getPaginationLabel"
-    row-key="id"
-    :rows="rows"
-    :rows-per-page-label="$t('general.rows_per_page')"
-    :columns="props.columns"
-    :visible-columns="visibleColumns"
-    :filter="filter"
-    :grid="$q.screen.lt.sm"
-    class="softpar-table q-pa-sm"
-    @row-click="onRowClick"
-  >
-    <template #top>
-      <div
-        class="flex full-width justify-between align-center q-mb-md q-pl-sm"
-        style="gap: 1rem"
-      >
-        <q-input
-          v-if="mostrarCampoPesquisa"
-          v-model="filter"
-          debounce="250"
-          :placeholder="$t('general.search')"
-          clearable
-          autofocus
-          class=""
-          color="primary"
-        >
-          <template #append>
-            <q-icon name="mdi-magnify" />
-          </template>
-        </q-input>
-
-        <q-select
-          v-if="mostrarSelecaoDeColunas"
-          v-model="visibleColumns"
-          multiple
-          dense
-          outlined
-          options-outlined
-          :display-value="$q.lang.table.columns"
-          emit-value
-          map-options
-          :options="mapColuns"
-          option-value="name"
-          style="width: 150px"
-          options-selected-class="text-bold"
-        />
-
-        <q-btn
-          v-if="mostrarBotaoFullscreen"
-          flat
-          @click="fullscreen = !fullscreen"
-        >
-          <q-icon name="mdi-fullscreen" />
-        </q-btn>
-
-        <q-space />
-
-        <q-btn-dropdown
-          v-if="props.dropDown"
-          color="primary"
-          :label="$t('general.options')"
-        />
-
-        <q-btn
-          v-if="props.addItem"
-          color="primary"
-          padding="10px 16px"
-          :outline="props.outlineAdd"
-          :label="$t('general.add')"
-          @click="onAddItem"
-        >
-        </q-btn>
-      </div>
-    </template>
-
-    <template #body-cell-status="{ value, row }">
-      <q-td style="width: 8%">
-        <q-item-section>
-          <span class="text-center">
-            <div v-if="row.status && value" class="ativo body2 text-positive">
-              {{ $t("general.active") }}
-            </div>
-            <div v-if="!row.status" class="inativo body2 text-accent">
-              {{ $t("general.inactive") }}
-            </div>
-          </span>
-        </q-item-section>
-      </q-td>
-    </template>
-
-    <template #body-cell-ativo="{ value, row }">
-      <q-td style="width: 8%">
-        <q-item-section>
-          <span class="text-center">
-            <div v-if="row.ativo && value" class="ativo body2 text-positive">
-              {{ $t("general.active") }}
-            </div>
-            <div v-if="row.ativo && !value" class="ativo body2 text-positive">
-              {{ $t("general.active") }}
-            </div>
-            <div v-if="!row.ativo" class="inativo body2 text-accent">
-              {{ $t("general.active") }}
-            </div>
-          </span>
-        </q-item-section>
-      </q-td>
-    </template>
-
-    <template v-if="!props.hideNoDataLabel" #no-data>
-      <div class="q-my-md row justify-center full-width">
-        <q-spinner v-if="loading" color="primary" size="30px" />
-        <div v-else class="q-pa-md body2">
-          {{ $t("errors.no_records_found") }}
-        </div>
-      </div>
-    </template>
-
-    <template v-for="(index, name) in $slots" #[name]="data">
-      <slot :name="name" v-bind="data"></slot>
-    </template>
-  </q-table>
-</template>
-
-<script setup>
-import { ref, onMounted, toRaw, watch } from "vue";
-import { useRouter } from "vue-router";
-
-const emit = defineEmits(["onRowClick", "onAddItem", "noRows"]);
-
-const props = defineProps({
-  // colunas de configuração da tabela
-  columns: {
-    type: Array,
-    required: true,
-  },
-
-  // rota da api, ex: /clientes
-  apiCall: {
-    type: Function,
-    required: true,
-  },
-
-  // botao de adicionar com aparencia de outline
-  outlineAdd: {
-    type: Boolean,
-    default: false,
-  },
-
-  // ir para sub pagina on row click
-  openItem: {
-    type: Boolean,
-    default: false,
-  },
-  // rota da sub page
-  openItemRoute: {
-    type: String,
-    default: "",
-  },
-
-  // botao de adicionar
-  addItem: {
-    type: Boolean,
-    default: true,
-  },
-
-  // botao de opcoes
-  dropDown: {
-    type: Boolean,
-    default: false,
-  },
-
-  // botao de adicionar route
-  addItemRoute: {
-    type: String,
-    default: "",
-  },
-
-  // quantidade de items por pagina
-  rowsPerPage: {
-    type: Number,
-    default: 10,
-  },
-
-  comecarDesativado: {
-    type: Boolean,
-    default: false,
-  },
-
-  mostrarSelecaoDeColunas: {
-    type: Boolean,
-    default: false,
-  },
-
-  mostrarBotaoFullscreen: {
-    type: Boolean,
-    default: false,
-  },
-
-  // mostrarToggleInativos: {
-  //   type: Boolean,
-  //   default: false,
-  // },
-
-  mostrarCampoPesquisa: {
-    type: Boolean,
-    default: true,
-  },
-
-  noApiCall: {
-    type: Boolean,
-    default: false,
-  },
-
-  hideNoDataLabel: {
-    type: Boolean,
-    default: false,
-  },
-
-  // labelInativo: {
-  //   type: String,
-  //   default: "Exibir inativos",
-  // },
-});
-
-const router = useRouter();
-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(
-  () => props.apiCall,
-  async () => {
-    await onRequest();
-  },
-);
-
-// remove as colunas obrigatórias do filtro de colunas
-const mapColuns = props.columns.reduce((accm, column) => {
-  if (!column.required) {
-    accm.push(column);
-  }
-  return accm;
-}, []);
-
-// as colunas que serão carregadas
-const visibleColumns = ref(mapColuns.map((column) => column.name));
-
-const onRowClick = (evt, row, index) => {
-  const item = toRaw(row);
-  if (props.openItem) {
-    if (props.openItemRoute) {
-      router.push({ name: props.openItemRoute, params: { id: item.id } });
-    } else {
-      emit("onRowClick", { evt, row, index });
-    }
-  }
-};
-
-const onAddItem = () => {
-  if (props.addItem) {
-    if (props.addItemRoute) {
-      router.push({ name: props.addItemRoute });
-    } else {
-      emit("onAddItem");
-    }
-  }
-};
-
-// busca os dados do banco com filtros e pagination
-const onRequest = async () => {
-  // const filter = params.filter;
-  if (props.noApiCall) {
-    loading.value = false;
-    return;
-  }
-  // inicia o loading
-  loading.value = true;
-
-  // pega os dados do servidor
-  const response = await props.apiCall();
-  // limpa os dados atuais e adiciona os novos
-  rows.value.splice(0, rows.value.length, ...response);
-
-  // if (props.mostrarToggleInativos && !showInativos.value) {
-  //   inativos.value = rows.value.filter(
-  //     (row) => row.status === false || row.ativo === false,
-  //   );
-  //   rows.value = rows.value.filter((row) => row.ativo || row.status === true);
-  // }
-
-  if (rows.value.length == 0) {
-    emit("noRows");
-  }
-  // finaliza o loading
-  loading.value = false;
-};
-
-onMounted(async () => {
-  // faz a primeira requisição
-  await onRequest({
-    filter: undefined,
-  });
-  if (props.comecarDesativado) {
-    visibleColumns.value = mapColuns
-      .map((column) => column.required)
-      .map((column) => column.name);
-  }
-});
-</script>
-
-<style lang="scss">
-@import "src/css/table.scss";
-</style>

+ 0 - 472
src/components/geral/DefaultTableServerSide.vue

@@ -1,472 +0,0 @@
-<template>
-  <q-table
-    v-model:fullscreen="fullscreen"
-    v-model:pagination="pagination"
-    row-key="id"
-    flat
-    :class="
-      props.table
-        ? 'softpar-table bg-background table-bottom'
-        : 'softpar-table bg-background '
-    "
-    :pagination-label="getPaginationLabel"
-    :rows="rows"
-    :rows-per-page-label="$t('general.rows_per_page')"
-    :columns="props.columns"
-    :visible-columns="visibleColumns"
-    :filter="pagination.filter"
-    v-bind="$attrs"
-    @row-click="onRowClick"
-  >
-    <template #top>
-      <q-input
-        v-if="mostrarCampoPesquisa"
-        v-model="pagination.filter"
-        outlined
-        dense
-        debounce="500"
-        placeholder="Buscar"
-        style="min-width: 400px"
-        clearable
-        autofocus
-      >
-        <template #append>
-          <q-icon name="mdi-magnify" />
-        </template>
-      </q-input>
-
-      <!-- <q-checkbox
-        v-if="props.mostrarToggleInativos"
-        v-model="pagination.showInativos"
-        class="q-ml-sm"
-        :label="props.labelInativo"
-        dense
-        color="secondary"
-      /> -->
-
-      <q-select
-        v-if="mostrarSelecaoDeColunas"
-        v-model="visibleColumns"
-        class="q-ml-md"
-        multiple
-        dense
-        outlined
-        options-outlined
-        :display-value="$q.lang.table.columns"
-        emit-value
-        map-options
-        :options="mapColuns"
-        option-value="name"
-        style="min-width: 150px"
-        options-selected-class="text-bold"
-      />
-
-      <q-btn
-        v-if="mostrarBotaoFullscreen"
-        flat
-        class="q-ml-md"
-        @click="fullscreen = !fullscreen"
-      >
-        <q-icon name="mdi-fullscreen" />
-      </q-btn>
-
-      <q-space />
-
-      <q-btn-dropdown
-        v-if="props.dropDown"
-        class="q-mr-md"
-        color="primary"
-        label="opcoes"
-      />
-
-      <q-btn
-        v-if="props.addItem"
-        class="button-secondary"
-        color="primary"
-        padding="12px 16px"
-        :outline="props.outlineAdd"
-        :label="props.labelAdd"
-        @click="onAddItem"
-      >
-      </q-btn>
-    </template>
-
-    <template #body-cell-status="{ value, row }">
-      <q-td style="width: 8%">
-        <q-item-section>
-          <span class="text-center">
-            <div v-if="row.status && value" class="ativo body2 text-positive">
-              {{ $t("general.active") }}
-            </div>
-            <div v-if="!row.status" class="inativo body2 text-accent">
-              {{ $t("general.inactive") }}
-            </div>
-          </span>
-        </q-item-section>
-      </q-td>
-    </template>
-
-    <template #body-cell-ativo="{ value, row }">
-      <q-td style="width: 8%">
-        <q-item-section>
-          <span class="text-center">
-            <div v-if="row.ativo && value" class="ativo body2 text-positive">
-              {{ $t("general.active") }}
-            </div>
-            <div v-if="row.ativo && !value" class="ativo body2 text-positive">
-              {{ $t("general.active") }}
-            </div>
-            <div v-if="!row.ativo" class="inativo body2 text-accent">
-              {{ $t("general.active") }}
-            </div>
-          </span>
-        </q-item-section>
-      </q-td>
-    </template>
-
-    <template v-if="!props.hideNoDataLabel" #no-data>
-      <div class="q-my-md row justify-center full-width">
-        <q-spinner v-if="loading" color="primary" size="30px" />
-        <div v-else class="q-pa-md body2">
-          {{ $t("errors.no_records_found") }}
-        </div>
-      </div>
-    </template>
-
-    <template #bottom="scope">
-      <div class="flex full-width justify-end">
-        <div class="flex items-center">
-          {{ $t("general.rows_per_page") }}
-          <q-select
-            v-model="pagination.rowsPerPage"
-            class="q-mx-sm"
-            dense
-            borderless
-            :options="rowsPerPageOptions"
-          >
-            <template #option="selectData">
-              <q-item v-bind="selectData.itemProps">
-                <q-item-section>
-                  <q-item-label>{{
-                    selectData.opt == 0 ? $t("general.all") : selectData.opt
-                  }}</q-item-label>
-                </q-item-section>
-              </q-item>
-            </template>
-          </q-select>
-        </div>
-        <div class="flex items-center">
-          {{ pagination.from + "-" + pagination.to }} {{ $t("labels.of") }}
-          {{ pagination.rowsNumber }}
-        </div>
-        <div class="flex items-center">
-          <q-btn
-            icon="mdi-chevron-left"
-            color="grey-8"
-            round
-            dense
-            flat
-            :disable="scope.isFirstPage"
-            @click="prevPage"
-          />
-          <q-btn
-            icon="mdi-chevron-right"
-            color="grey-8"
-            round
-            dense
-            flat
-            :disable="scope.isLastPage"
-            @click="nextPage"
-          />
-        </div>
-      </div>
-    </template>
-
-    <template v-for="(index, name) in $slots" #[name]="data">
-      <slot :name="name" v-bind="data"></slot>
-    </template>
-  </q-table>
-</template>
-
-<script setup>
-import { ref, onMounted, toRaw, watch } from "vue";
-import { useRouter } from "vue-router";
-
-const emit = defineEmits([
-  "onRowClick",
-  "onAddItem",
-  "noRows",
-  "togglePrincipal",
-]);
-
-const props = defineProps({
-  // colunas de configuração da tabela
-  columns: {
-    type: Array,
-    required: true,
-  },
-
-  // rota da api, ex: /clientes
-  apiRoute: {
-    type: Function,
-    required: true,
-  },
-
-  labelAdd: {
-    type: String,
-    default: "Adicionar",
-  },
-
-  // botao de adicionar com aparencia de outline
-  outlineAdd: {
-    type: Boolean,
-    default: false,
-  },
-
-  // ir para sub pagina on row click
-  openItem: {
-    type: Boolean,
-    default: false,
-  },
-  // rota da sub page
-  openItemRoute: {
-    type: String,
-    default: "",
-  },
-
-  // botao de adicionar
-  addItem: {
-    type: Boolean,
-    default: true,
-  },
-
-  // botao de opcoes
-  dropDown: {
-    type: Boolean,
-    default: false,
-  },
-
-  // botao de adicionar route
-  addItemRoute: {
-    type: String,
-    default: "",
-  },
-
-  // quantidade de items por pagina
-  rowsPerPage: {
-    type: Number,
-    default: 10,
-  },
-
-  comecarDesativado: {
-    type: Boolean,
-    default: false,
-  },
-
-  mostrarSelecaoDeColunas: {
-    type: Boolean,
-    default: false,
-  },
-
-  mostrarBotaoFullscreen: {
-    type: Boolean,
-    default: false,
-  },
-
-  // mostrarToggleInativos: {
-  //   type: Boolean,
-  //   default: false,
-  // },
-
-  mostrarCampoPesquisa: {
-    type: Boolean,
-    default: true,
-  },
-
-  noApiRoute: {
-    type: Boolean,
-    default: false,
-  },
-
-  table: {
-    type: Boolean,
-    default: false,
-  },
-
-  hideNoDataLabel: {
-    type: Boolean,
-    default: false,
-  },
-
-  // labelInativo: {
-  //   type: String,
-  //   default: "Exibir inativos",
-  // },
-});
-
-const router = useRouter();
-const rows = ref([]);
-const loading = ref(true);
-const fullscreen = ref(false);
-const rowsPerPageOptions = [10, 15, 25, 50];
-const pagination = ref({
-  filter: undefined,
-  page: 1,
-  rowsPerPage: props.rowsPerPage,
-  rowsNumber: 0,
-  showInativos: false,
-});
-let mounted = false;
-
-const getPaginationLabel = (from, to, last) => {
-  return `${from}-${to} de ${last}`;
-};
-
-watch(
-  () => props.apiRoute,
-  () => {
-    onRequest();
-  },
-);
-
-let isFetching = false;
-let oldRowsPerPage = pagination.value.rowsPerPage;
-let oldFilter = pagination.value.filter;
-let oldInativos = pagination.value.showInativos;
-watch(
-  pagination,
-  async (value) => {
-    if (loading.value) return;
-    if (isFetching) return;
-    if (!mounted) return;
-    if (value.rowsPerPage != oldRowsPerPage) {
-      isFetching = true;
-      await onRequest();
-      isFetching = false;
-      oldRowsPerPage = value.rowsPerPage;
-    }
-    if (value.filter != oldFilter) {
-      isFetching = true;
-      await onRequest();
-      isFetching = false;
-      oldFilter = value.filter;
-    }
-    if (value.showInativos != oldInativos) {
-      isFetching = true;
-      await onRequest();
-      isFetching = false;
-      oldInativos = value.showInativos;
-    }
-  },
-  { deep: true },
-);
-
-// remove as colunas obrigatórias do filtro de colunas
-const mapColuns = props.columns.reduce((accm, column) => {
-  if (!column.required) {
-    column.label = column.label.toUpperCase();
-    accm.push(column);
-  }
-  return accm;
-}, []);
-
-// as colunas que serão carregadas
-const visibleColumns = ref(mapColuns.map((column) => column.name));
-
-const onRowClick = (evt, row, index) => {
-  const item = toRaw(row);
-  if (props.openItem) {
-    if (props.openItemRoute) {
-      router.push({ name: props.openItemRoute, params: { id: item.id } });
-    } else {
-      emit("onRowClick", { evt, row, index });
-    }
-  }
-};
-
-const prevPage = () => {
-  pagination.value.page--;
-  onRequest();
-};
-
-const nextPage = () => {
-  pagination.value.page++;
-  onRequest();
-};
-
-const onAddItem = () => {
-  if (props.addItem) {
-    if (props.addItemRoute) {
-      router.push({ name: props.addItemRoute });
-    } else {
-      emit("onAddItem");
-    }
-  }
-};
-
-// busca os dados do banco com filtros e pagination
-const onRequest = async () => {
-  // const filter = params.filter;
-  if (props.noApiRoute) {
-    loading.value = false;
-    return;
-  }
-  // inicia o loading
-  loading.value = true;
-
-  // pega os dados do servidor
-  const response = await props.apiRoute();
-  // limpa os dados atuais e adiciona os novos
-  rows.value.splice(0, rows.value.length, ...response);
-
-  pagination.value.rowsNumber = response.data.result.total;
-  pagination.value.from = response.data.result.from;
-  pagination.value.to = response.data.result.to;
-
-  if (rows.value.length == 0) {
-    emit("noRows");
-  }
-  // finaliza o loading
-  loading.value = false;
-};
-
-onMounted(async () => {
-  await onRequest();
-  if (props.comecarDesativado) {
-    visibleColumns.value = mapColuns
-      .map((column) => column.required)
-      .map((column) => column.name);
-  }
-  mounted = true;
-});
-</script>
-
-<style lang="scss">
-@import "src/css/table.scss";
-
-.ativo {
-  justify-content: center;
-  align-items: center;
-  padding: 5px 12px;
-  gap: 10px;
-  background: #cfdab7;
-  border-radius: 24px;
-}
-
-.chip {
-  justify-content: center;
-  align-items: center;
-  padding: 5px 12px;
-  gap: 10px;
-  border-radius: 24px;
-}
-
-.inativo {
-  justify-content: center;
-  align-items: flex-start;
-  padding: 5px 12px;
-  gap: 10px;
-  background: #f7cfbb;
-  border-radius: 24px;
-}
-</style>

+ 100 - 155
src/components/layout/LeftMenuLayout.vue

@@ -1,7 +1,7 @@
 <template>
   <q-drawer
     v-bind="$attrs"
-    v-model="leftDrawerOpen"
+    :model-value="true"
     show-if-above
     no-swipe-close
     no-swipe-open
@@ -34,8 +34,8 @@
             :offset="[10, 10]"
             >{{
               miniState
-                ? $t("navigation.expand_menu")
-                : $t("navigation.collapse_menu")
+                ? $t('ui.navigation.expand_menu')
+                : $t('ui.navigation.collapse_menu')
             }}</q-tooltip
           >
         </q-btn>
@@ -80,7 +80,7 @@
                       style="font-size: 18px"
                     />
                   </q-item-section>
-                  <q-item-section>{{ $t("navigation.perfil") }}</q-item-section>
+                  <q-item-section>{{ $t("user.profile.singular") }}</q-item-section>
                 </div>
               </q-item>
               <q-item v-ripple clickable @click="logoutFn">
@@ -92,7 +92,7 @@
                       style="font-size: 18px"
                     />
                   </q-item-section>
-                  <q-item-section>{{ $t("navigation.logout") }}</q-item-section>
+                  <q-item-section>{{ $t('auth.logout') }}</q-item-section>
                 </div>
               </q-item>
             </q-list>
@@ -101,120 +101,122 @@
       </q-list>
 
       <q-list class="column no-wrap">
-        <template v-for="menu in menus" :key="menu.name">
-          <!-- Single Menu -->
-          <q-item
-            v-if="menu.type === 'single'"
-            v-ripple
-            clickable
-            exact-active-class="menu-selected"
-            exact
-            active-class="menu-selected"
-            :to="{ name: menu.name }"
-            class="q-my-xs"
-          >
-            <q-item-section avatar>
-              <q-icon :name="menu.icon" style="font-size: 18px" />
-            </q-item-section>
-            <q-item-section>{{ $t(menu.title) }}</q-item-section>
-            <q-tooltip
-              v-if="miniState"
-              anchor="center right"
-              self="center left"
-              :offset="[10, 10]"
-              >{{ $t(menu.title) }}</q-tooltip
+        <template v-for="item in navigationItems" :key="item.name">
+          <template v-if="item.permission">
+            <q-item
+              v-if="item.type === 'single'"
+              v-ripple
+              clickable
+              exact-active-class="menu-selected"
+              exact
+              active-class="menu-selected"
+              :to="{ name: item.name }"
+              class="q-my-xs"
             >
-          </q-item>
-          <!-- Expansive Menu with children -->
-          <div v-else>
-            <template v-if="!miniState">
+              <q-item-section avatar>
+                <q-icon :name="item.icon" style="font-size: 18px" />
+              </q-item-section>
+              <q-item-section>{{ $t(item.title) }}</q-item-section>
               <q-tooltip
                 v-if="miniState"
                 anchor="center right"
                 self="center left"
                 :offset="[10, 10]"
-                >{{ $t(menu.title) }}</q-tooltip
-              >
-              <q-expansion-item
-                v-model="isExpasionItemExpanded"
-                header-class="menu-item--spaced"
-                :class="{
-                  'menu-selected':
-                    childrenAreActive(menu.children) && !isExpasionItemExpanded,
-                }"
-                class="menu-item--spaced"
-              >
-                <template #header>
-                  <q-item-section avatar>
-                    <q-icon :name="menu.icon" style="font-size: 18px" />
-                  </q-item-section>
-                  <q-item-section>{{ $t(menu.title) }}</q-item-section>
-                </template>
-                <div v-for="child in menu.childrens" :key="child.name">
-                  <q-item
-                    v-ripple
-                    clickable
-                    :to="{ name: child.name }"
-                    exact
-                    exact-active-class="menu-selected"
-                    class="menu-item--spaced q-pl-lg"
-                  >
-                    <q-item-section avatar>
-                      <q-icon :name="child.icon" style="font-size: 18px" />
-                    </q-item-section>
-                    <q-item-section>{{ $t(child.title) }}</q-item-section>
-                    <q-tooltip
-                      v-if="miniState"
-                      anchor="center right"
-                      self="center left"
-                      :offset="[10, 10]"
-                      >{{ $t(child.title) }}</q-tooltip
-                    >
-                  </q-item>
-                </div>
-              </q-expansion-item>
-            </template>
-            <template v-else>
-              <q-item
-                v-ripple
-                clickable
-                exact
-                exact-active-class="menu-selected"
-                class="menu-item--spaced"
+                >{{ $t(item.title) }}</q-tooltip
               >
-                <q-item-section avatar>
-                  <q-icon :name="menu.icon" style="font-size: 18px" />
-                </q-item-section>
-                <q-item-section>{{ $t(menu.title) }}</q-item-section>
+            </q-item>
+            <!-- Expansive Menu with children -->
+            <div v-else>
+              <template v-if="!miniState">
                 <q-tooltip
                   v-if="miniState"
                   anchor="center right"
                   self="center left"
                   :offset="[10, 10]"
-                  >{{ $t(menu.title) }}</q-tooltip
+                  >{{ $t(item.title) }}</q-tooltip
                 >
-                <q-menu anchor="center right" self="top start">
-                  <q-list>
+                <q-expansion-item
+                  v-model="isExpasionItemExpanded"
+                  header-class="menu-item--spaced"
+                  :class="{
+                    'menu-selected':
+                      childrenAreActive(item.children) &&
+                      !isExpasionItemExpanded,
+                  }"
+                  class="menu-item--spaced"
+                >
+                  <template #header>
+                    <q-item-section avatar>
+                      <q-icon :name="item.icon" style="font-size: 18px" />
+                    </q-item-section>
+                    <q-item-section>{{ $t(item.title) }}</q-item-section>
+                  </template>
+                  <div v-for="child in item.childrens" :key="child.name">
                     <q-item
-                      v-for="child in menu.childrens"
-                      :key="child.name"
                       v-ripple
-                      v-close-popup
                       clickable
                       :to="{ name: child.name }"
                       exact
                       exact-active-class="menu-selected"
+                      class="menu-item--spaced q-pl-lg"
                     >
                       <q-item-section avatar>
                         <q-icon :name="child.icon" style="font-size: 18px" />
                       </q-item-section>
                       <q-item-section>{{ $t(child.title) }}</q-item-section>
+                      <q-tooltip
+                        v-if="miniState"
+                        anchor="center right"
+                        self="center left"
+                        :offset="[10, 10]"
+                        >{{ $t(child.title) }}</q-tooltip
+                      >
                     </q-item>
-                  </q-list>
-                </q-menu>
-              </q-item>
-            </template>
-          </div>
+                  </div>
+                </q-expansion-item>
+              </template>
+              <template v-else>
+                <q-item
+                  v-ripple
+                  clickable
+                  exact
+                  exact-active-class="menu-selected"
+                  class="menu-item--spaced"
+                >
+                  <q-item-section avatar>
+                    <q-icon :name="item.icon" style="font-size: 18px" />
+                  </q-item-section>
+                  <q-item-section>{{ $t(item.title) }}</q-item-section>
+                  <q-tooltip
+                    v-if="miniState"
+                    anchor="center right"
+                    self="center left"
+                    :offset="[10, 10]"
+                    >{{ $t(item.title) }}</q-tooltip
+                  >
+                  <q-menu anchor="center right" self="top start">
+                    <q-list>
+                      <q-item
+                        v-for="child in item.childrens"
+                        :key="child.name"
+                        v-ripple
+                        v-close-popup
+                        clickable
+                        :to="{ name: child.name }"
+                        exact
+                        exact-active-class="menu-selected"
+                      >
+                        <q-item-section avatar>
+                          <q-icon :name="child.icon" style="font-size: 18px" />
+                        </q-item-section>
+                        <q-item-section>{{ $t(child.title) }}</q-item-section>
+                      </q-item>
+                    </q-list>
+                  </q-menu>
+                </q-item>
+              </template>
+            </div>
+          </template>
         </template>
       </q-list>
       <q-list class="q-mt-auto">
@@ -240,11 +242,11 @@
   </q-drawer>
 </template>
 <script setup>
-import { ref, onMounted, watch, watchEffect } from "vue";
+import { ref, watch, watchEffect } from "vue";
 import { useAuth } from "src/composables/useAuth";
-import { permissionStore } from "src/stores/permission";
 import { useRouter, useRoute } from "vue-router";
 import { userStore } from "src/stores/user";
+import { navigationStore } from "src/stores/navigation";
 import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
 import LogoSoftparMini from "src/assets/softpar_logo_mini.svg";
@@ -256,10 +258,10 @@ const { logout } = useAuth();
 const router = useRouter();
 const route = useRoute();
 const user_store = userStore();
+const { navigationItems } = navigationStore();
 
 const version = "0.0.1";
 
-const leftDrawerOpen = ref(true);
 const miniState = ref(Cookies.get("miniState") === "true" ?? false);
 
 const childrenAreActive = (children) => {
@@ -275,67 +277,14 @@ const someAvatar = () => {
 
 const isExpasionItemExpanded = ref(false);
 
-const menus = ref([
-  {
-    type: "single",
-    title: "navigation.dashboard",
-    name: "DashboardPage",
-    icon: "mdi-home-variant-outline",
-    disable: false,
-    permission: false,
-    permissionScope: "dashboard",
-  },
-  {
-    type: "expansive",
-    title: "navigation.registration",
-    icon: "mdi-cog-outline",
-    disable: false,
-    permission: false,
-    permissionScope: "config",
-    childrens: [
-      {
-        type: "single",
-        title: "navigation.users",
-        name: "UsersPage",
-        icon: "mdi-account-multiple-outline",
-        disable: false,
-        permission: false,
-        permissionScope: "config.user",
-      },
-    ],
-  },
-]);
-
-const getMenuAccess = () => {
-  const { getAccess } = permissionStore();
-  menus.value = menus.value
-    .map((menu) => {
-      if (menu.type === "expansive") {
-        if (getAccess(menu.permissionScope, "menu")) {
-          menu.permission = true;
-        }
-        menu.childrens = menu.childrens.filter((children) => {
-          children.permission = getAccess(children.permissionScope, "menu");
-          return children.permission;
-        });
-        return menu.childrens.length > 0 ? menu : null;
-      } else {
-        menu.permission = getAccess(menu.permissionScope, "menu");
-        return menu;
-      }
-    })
-    .filter((menu) => menu !== null);
-};
-
 watchEffect(() => {
   if ($q.screen.lt.md) {
-    miniState.value = true
+    miniState.value = true;
     if (Array.isArray(isExpasionItemExpanded.value)) {
       isExpasionItemExpanded.value.forEach((expansion, index) => {
         isExpasionItemExpanded.value[index] = false;
       });
     } else {
-      console.log("isExpasionItemExpanded", isExpasionItemExpanded.value);
       isExpasionItemExpanded.value = false;
     }
   }
@@ -353,10 +302,6 @@ const openUrl = (url) => {
 watch(miniState, () => {
   Cookies.set("miniState", miniState.value);
 });
-
-onMounted(() => {
-  getMenuAccess();
-});
 </script>
 
 <style lang="scss" scoped>

+ 52 - 104
src/components/layout/LeftMenuLayoutMobile.vue

@@ -10,55 +10,57 @@
   >
     <div class="column full-height q-pa-sm no-wrap">
       <q-list class="column no-wrap">
-        <template v-for="menu in menus" :key="menu.name">
-          <!-- Single Menu -->
-          <q-item
-            v-if="menu.type === 'single'"
-            v-ripple
-            clickable
-            exact-active-class="menu-selected"
-            exact
-            active-class="menu-selected"
-            :to="{ name: menu.name }"
-            class="q-my-xs"
-          >
-            <q-item-section avatar>
-              <q-icon :name="menu.icon" style="font-size: 18px" />
-            </q-item-section>
-            <q-item-section>{{ $t(menu.title) }}</q-item-section>
-          </q-item>
-          <!-- Expansive Menu with children -->
-          <q-expansion-item
-            v-else
-            v-model="isExpasionItemExpanded"
-            header-class="menu-item--spaced"
-            :class="{
-              'menu-selected':
-                childrenAreActive(menu.children) && !isExpasionItemExpanded,
-            }"
-          >
-            <template #header>
+        <template v-for="item in navigationItems" :key="item.name">
+          <template v-if="item.permission">
+            <!-- Single Menu -->
+            <q-item
+              v-if="item.type === 'single'"
+              v-ripple
+              clickable
+              exact-active-class="menu-selected"
+              exact
+              active-class="menu-selected"
+              :to="{ name: item.name }"
+              class="q-my-xs"
+            >
               <q-item-section avatar>
-                <q-icon :name="menu.icon" style="font-size: 18px" />
+                <q-icon :name="item.icon" style="font-size: 18px" />
               </q-item-section>
-              <q-item-section>{{ $t(menu.title) }}</q-item-section>
-            </template>
-            <div v-for="child in menu.childrens" :key="child.name">
-              <q-item
-                v-ripple
-                clickable
-                :to="{ name: child.name }"
-                exact
-                exact-active-class="menu-selected"
-                class="menu-item--spaced q-pl-lg"
-              >
+              <q-item-section>{{ $t(item.title) }}</q-item-section>
+            </q-item>
+            <!-- Expansive Menu with children -->
+            <q-expansion-item
+              v-else
+              v-model="isExpasionItemExpanded"
+              header-class="menu-item--spaced"
+              :class="{
+                'menu-selected':
+                  childrenAreActive(item.children) && !isExpasionItemExpanded,
+              }"
+            >
+              <template #header>
                 <q-item-section avatar>
-                  <q-icon :name="child.icon" style="font-size: 18px" />
+                  <q-icon :name="item.icon" style="font-size: 18px" />
                 </q-item-section>
-                <q-item-section>{{ $t(child.title) }}</q-item-section>
-              </q-item>
-            </div>
-          </q-expansion-item>
+                <q-item-section>{{ $t(item.title) }}</q-item-section>
+              </template>
+              <div v-for="child in item.childrens" :key="child.name">
+                <q-item
+                  v-ripple
+                  clickable
+                  :to="{ name: child.name }"
+                  exact
+                  exact-active-class="menu-selected"
+                  class="menu-item--spaced q-pl-lg"
+                >
+                  <q-item-section avatar>
+                    <q-icon :name="child.icon" style="font-size: 18px" />
+                  </q-item-section>
+                  <q-item-section>{{ $t(child.title) }}</q-item-section>
+                </q-item>
+              </div>
+            </q-expansion-item>
+          </template>
         </template>
       </q-list>
 
@@ -80,14 +82,16 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import { permissionStore } from "src/stores/permission";
+import { ref } from "vue";
 import { useRoute } from "vue-router";
+import { navigationStore } from "src/stores/navigation";
 import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
 const route = useRoute();
 
-const leftDrawerOpen = ref(false);
+const leftDrawerOpen = defineModel();
+
+const { navigationItems } = navigationStore();
 
 const childrenAreActive = (children) => {
   if (!children) return false;
@@ -98,65 +102,9 @@ const childrenAreActive = (children) => {
 
 const isExpasionItemExpanded = ref(false);
 
-const menus = ref([
-  {
-    type: "single",
-    title: "navigation.dashboard",
-    name: "DashboardPage",
-    icon: "mdi-home-variant-outline",
-    disable: false,
-    permission: false,
-    permissionScope: "dashboard",
-  },
-  {
-    type: "expansive",
-    title: "navigation.registration",
-    icon: "mdi-cog-outline",
-    disable: false,
-    permission: false,
-    permissionScope: "config",
-    childrens: [
-      {
-        type: "single",
-        title: "navigation.users",
-        name: "UsersPage",
-        icon: "mdi-account-multiple-outline",
-        disable: false,
-        permission: false,
-        permissionScope: "config.user",
-      },
-    ],
-  },
-]);
-
-const getMenuAccess = () => {
-  const { getAccess } = permissionStore();
-  menus.value = menus.value
-    .map((menu) => {
-      if (menu.type === "expansive") {
-        if (getAccess(menu.permissionScope, "menu")) {
-          menu.permission = true;
-        }
-        menu.childrens = menu.childrens.filter((children) => {
-          children.permission = getAccess(children.permissionScope, "menu");
-          return children.permission;
-        });
-        return menu.childrens.length > 0 ? menu : null;
-      } else {
-        menu.permission = getAccess(menu.permissionScope, "menu");
-        return menu;
-      }
-    })
-    .filter((menu) => menu !== null);
-};
-
 const openUrl = (url) => {
   window.open(url, "_blank");
 };
-
-onMounted(() => {
-  getMenuAccess();
-});
 </script>
 
 <style lang="scss" scoped>

+ 168 - 0
src/components/regions/CitySelect.vue

@@ -0,0 +1,168 @@
+<template>
+  <q-select
+    v-model="selectedCity"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="cityOptions"
+    :label="label"
+    :loading="loading"
+    :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.city')"
+    :rules="rules"
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getCities } from "src/api/city";
+import { ref, onMounted, watch } from "vue";
+import { useI18n } from "vue-i18n";
+
+const emit = defineEmits(["selectedStateId"]);
+
+const { state, label, rules, initialId } = defineProps({
+  // This country prop is here for future use, maybe
+  country: {
+    type: Object,
+    required: false,
+    default: () => {
+      return {
+        label: "Brasil",
+        value: 1,
+      };
+    },
+  },
+  state: {
+    type: Object,
+    required: false,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: () => useI18n().t("ui.navigation.city"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  initialId: {
+    type: Number,
+    required: false,
+    default: null,
+  },
+});
+
+const selectedCity = defineModel();
+
+const loading = ref(false);
+const filteredCities = ref([]);
+const baseCities = ref([]);
+const cityOptions = ref([]);
+
+const filterFn = async (val, update) => {
+  if (!val) {
+    cityOptions.value = filteredCities.value.map((city) => ({
+      label: city.name,
+      value: city.id,
+      state_id: city.state_id,
+    }));
+  } else {
+    const needle = val.toLowerCase();
+    const cities = filteredCities.value.filter(
+      (v) => v.name.toLowerCase().indexOf(needle) > -1,
+    );
+    cityOptions.value = cities.map((city) => ({
+      label: city.name,
+      value: city.id,
+      state_id: city.state_id,
+    }));
+  }
+
+  update();
+};
+
+const selectCityByName = (name) => {
+  if (selectedCity.value?.label === name) {
+    return;
+  }
+  selectedCity.value = cityOptions.value.find((city) => city.label === name);
+};
+
+const selectCityById = (id) => {
+  if (selectedCity.value?.value === id) {
+    return;
+  }
+  selectedCity.value = cityOptions.value.find((city) => city.value === id);
+};
+
+watch(
+  () => state,
+  (value, oldValue) => {
+    if (
+      value?.value != oldValue?.value &&
+      value?.value != selectedCity.value?.state_id
+    ) {
+      selectedCity.value = null;
+    }
+    if (value) {
+      filteredCities.value = baseCities.value.filter(
+        (city) => city.state_id === value.value,
+      );
+      cityOptions.value = filteredCities.value.map((city) => ({
+        label: city.name,
+        value: city.id,
+        state_id: city.state_id,
+      }));
+    } else {
+      filteredCities.value = baseCities.value;
+      cityOptions.value = baseCities.value.map((city) => ({
+        label: city.name,
+        value: city.id,
+        state_id: city.state_id,
+      }));
+    }
+  },
+  { immediate: true },
+);
+
+watch(selectedCity, () => {
+  if (selectedCity.value?.state_id) {
+    emit("selectedStateId", selectedCity.value.state_id);
+  }
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    baseCities.value = await getCities();
+    filteredCities.value = baseCities.value;
+    cityOptions.value = baseCities.value.map((city) => ({
+      label: city.name,
+      value: city.id,
+      state_id: city.state_id,
+    }));
+    if (initialId) {
+      selectCityById(initialId);
+    }
+  } catch (e) {
+    console.log(e);
+  } finally {
+    loading.value = false;
+  }
+});
+
+defineExpose({
+  selectCityByName,
+  selectCityById,
+});
+</script>

+ 97 - 0
src/components/regions/CountrySelect.vue

@@ -0,0 +1,97 @@
+<template>
+  <q-select
+    v-model="selectedCountry"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="countryOptions"
+    :label="label"
+    :loading="loading"
+    :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.country')"
+    :rules="rules"
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getCountries } from "src/api/country";
+import { ref, onMounted } from "vue";
+import { useI18n } from "vue-i18n";
+
+const { label, rules } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("ui.navigation.country"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
+
+const selectedCountry = defineModel();
+
+const loading = ref(false);
+const baseCountry = ref([]);
+const countries = ref([]);
+const countryOptions = ref([]);
+
+const filterFn = async (val, update) => {
+  const filter = () => {
+    const needle = val.toLowerCase();
+    countries.value = baseCountry.value.filter((v) =>
+      v.name.toLowerCase().includes(needle),
+    );
+    countryOptions.value = countries.value.map((country) => ({
+      label: country.name,
+      value: country.id,
+    }));
+  };
+
+  update(filter);
+};
+
+const selectCountryByName = (name) => {
+  if (selectedCountry.value && selectedCountry.value.label === name) return;
+  selectedCountry.value = countryOptions.value.find(
+    (country) => country.label === name,
+  );
+};
+
+const selectCountryById = (id) => {
+  if (selectedCountry.value && selectedCountry.value.id === id) return;
+  selectedCountry.value = countryOptions.value.find(
+    (country) => country.value === id,
+  );
+};
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    baseCountry.value = await getCountries();
+    countryOptions.value = baseCountry.value.map((country) => ({
+      label: country.name,
+      value: country.id,
+    }));
+  } catch (e) {
+    console.log(e);
+  } finally {
+    loading.value = false;
+  }
+});
+
+defineExpose({
+  selectCountryByName,
+  selectCountryById,
+});
+</script>

+ 177 - 0
src/components/regions/StateSelect.vue

@@ -0,0 +1,177 @@
+<template>
+  <q-select
+    v-model="selectedState"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="stateOptions"
+    :label="label"
+    :loading="loading"
+    :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.state')"
+    :rules="rules"
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getStates } from "src/api/state";
+import { ref, onMounted, watch } from "vue";
+import { useI18n } from "vue-i18n";
+
+const emit = defineEmits(["selectedCountryId"]);
+
+const { country, label, rules, initialId } = defineProps({
+  country: {
+    type: Object,
+    required: false,
+    default: () => {
+      return {
+        label: "Brasil",
+        value: 1,
+      };
+    },
+  },
+  label: {
+    type: String,
+    default: () => useI18n().t("ui.navigation.state"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  initialId: {
+    type: Number,
+    required: false,
+    default: null,
+  },
+});
+
+const selectedState = defineModel();
+
+const loading = ref(false);
+const filteredStates = ref([]);
+const baseStates = ref([]);
+const stateOptions = ref([]);
+
+const filterFn = (val, update) => {
+  if (!val) {
+    stateOptions.value = filteredStates.value.map((state) => ({
+      label: state.name,
+      value: state.id,
+      code: state.code,
+      country_id: state.country_id,
+    }));
+  } else {
+    const needle = val.toLowerCase();
+    filteredStates.value = filteredStates.value.filter(
+      (v) => v.name.toLowerCase().indexOf(needle) > -1,
+    );
+    stateOptions.value = filteredStates.value.map((state) => ({
+      label: state.name,
+      value: state.id,
+      code: state.code,
+      country_id: state.country_id,
+    }));
+  }
+
+  update();
+};
+
+const selectStateById = async (id) => {
+  if (selectedState.value?.value === id) {
+    return;
+  }
+  selectedState.value = stateOptions.value.find((state) => state.value === id);
+};
+
+const selectStateByName = (name) => {
+  if (selectedState.value?.label === name) {
+    return;
+  }
+  selectedState.value = stateOptions.value.find(
+    (state) => state.label === name,
+  );
+};
+
+const selectStateByCode = (code) => {
+  if (selectedState.value?.code === code) {
+    return;
+  }
+  selectedState.value = stateOptions.value.find((state) => state.code === code);
+};
+
+watch(
+  () => country,
+  (value, oldValue) => {
+    if (
+      value?.value != oldValue?.value &&
+      value?.value != selectedState.value?.country_id
+    ) {
+      selectedState.value = null;
+    }
+    if (value) {
+      filteredStates.value = baseStates.value.filter(
+        (state) => state.country_id === value.value,
+      );
+      stateOptions.value = filteredStates.value.map((state) => ({
+        label: state.name,
+        value: state.id,
+        code: state.code,
+        country_id: state.country_id,
+      }));
+    } else {
+      filteredStates.value = baseStates.value;
+      stateOptions.value = baseStates.value.map((state) => ({
+        label: state.name,
+        value: state.id,
+        code: state.code,
+        country_id: state.country_id,
+      }));
+    }
+  },
+  { immediate: true },
+);
+
+watch(selectedState, () => {
+  if (selectedState.value?.country_id) {
+    emit("selectedCountryId", selectedState.value.country_id);
+  }
+});
+
+onMounted(async () => {
+  try {
+    loading.value = true;
+    baseStates.value = await getStates();
+    filteredStates.value = baseStates.value;
+    stateOptions.value = baseStates.value.map((state) => ({
+      label: state.name,
+      value: state.id,
+      code: state.code,
+      country_id: state.country_id,
+    }));
+    if (initialId) {
+      selectStateById(initialId);
+    }
+  } catch (e) {
+    console.log(e);
+  } finally {
+    loading.value = false;
+  }
+});
+
+defineExpose({
+  selectStateById,
+  selectStateByName,
+  selectStateByCode,
+});
+</script>

+ 26 - 0
src/composables/useFormUpdateTracker.js

@@ -0,0 +1,26 @@
+import { reactive, computed } from "vue";
+
+export const useFormUpdateTracker = (initalFormValue) => {
+  const form = reactive({ ...initalFormValue });
+  const originalForm = { ...initalFormValue };
+
+  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 {
+    form,
+    getUpdatedFields,
+    hasUpdatedFields,
+  };
+};

+ 18 - 9
src/composables/useInputRules.js

@@ -9,29 +9,38 @@ export const useInputRules = () => {
   const cpfPattern = /^[0-9]{3}\.[0-9]{3}\.[0-9]{3}-[0-9]{2}$/;
   const cnpjPattern = /^[0-9]{2}\.[0-9]{3}\.[0-9]{3}\/[0-9]{4}-[0-9]{2}$/;
   const passwordPattern = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/;
+  const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
 
   const inputRules = {
-    required: (value) => !!value || t("rules.required"),
-    requiredNumber: (value) => !isNaN(value) || t("rules.required"),
+    required: (value) => !!value || t("validation.rules.required"),
+    requiredNumber: (value) => !isNaN(value) || t("validation.rules.required"),
     requiredHideMessage: (value) => !!value,
     min: (min) => (value) =>
       value.length >= min ||
-      `${t("rules.min")} ${min} ${t("rules.characters")}`,
-    email: (value) => !value || emailPattern.test(value) || t("rules.email"),
+      `${t("validation.rules.min")} ${min} ${t("validation.rules.characters")}`,
+    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"),
     emails: (value) => {
       if (!value) return true;
       const emails = value.split(";").map((email) => email.trim());
       return (
         emails.every((email) => inputRules.email(email) === true) ||
-        t("rules.email")
+        t("validation.rules.email")
       );
     },
-    cpf: (value) => !value || cpfPattern.test(value) || t("rules.cpf"),
-    cnpj: (value) => !value || cnpjPattern.test(value) || t("rules.cnpj"),
+    cpf: (value) => !value || cpfPattern.test(value) || t("validation.rules.cpf"),
+    cnpj: (value) => !value || cnpjPattern.test(value) || t("validation.rules.cnpj"),
     samePassword: (otherValue) => (value) =>
-      value === otherValue || t("rules.same_password"),
+      value === otherValue || t("validation.rules.same_password"),
     password: (value) =>
-      !value || passwordPattern.test(value) || t("rules.password"),
+      !value || passwordPattern.test(value) || t("validation.rules.password"),
+    cep: (value) => {
+      return cepPattern.test(value) || t("validation.rules.cep");
+    },
   };
 
   return {

+ 59 - 0
src/composables/useScroll.js

@@ -0,0 +1,59 @@
+import { unref } from 'vue'
+
+export const useScroll = () => {
+  /**
+   * Scrolls to a specific component within a scroll container
+   * @param {Ref<Component>} targetRef - Reference to the component to scroll to
+   * @param {Ref<Component>} containerRef - Reference to the Quasar ScrollArea component
+   * @param {Object} options - Scroll options
+   * @param {number} options.offset - Offset from the top in pixels (default: 50)
+   * @param {number} options.duration - Animation duration in milliseconds (default: 150)
+   * @returns {boolean} - Whether the scroll was successful
+   */
+  const scrollToComponent = (targetRef, containerRef, options = {}) => {
+    const {
+      offset = 50,
+      duration = 150
+    } = options
+
+    const target = unref(targetRef)
+    const container = unref(containerRef)
+
+    const targetElement = target?.$el
+    const containerElement = container?.$el
+
+    if (!targetElement || !containerElement) {
+      console.warn('useScroll: Target or container element not found')
+      return false
+    }
+
+    try {
+      let currentElement = targetElement
+      let offsetTop = 0
+
+      // Calculate total offset up to the scroll container
+      while (currentElement && currentElement !== containerElement) {
+        offsetTop += currentElement.offsetTop
+        currentElement = currentElement.offsetParent
+      }
+
+      if (!currentElement) {
+        console.warn('useScroll: Target is not a child of the container')
+        return false
+      }
+
+      const targetPosition = Math.max(0, offsetTop - offset)
+
+      container.setScrollPosition('vertical', targetPosition, duration)
+
+      return true
+    } catch (error) {
+      console.error('useScroll: Error while scrolling', error)
+      return false
+    }
+  }
+
+  return {
+    scrollToComponent
+  }
+}

+ 23 - 0
src/css/app.scss

@@ -60,3 +60,26 @@ body.body--dark {
 
   background: #{map.get($colors-dark, "page")};
 }
+
+.q-card__actions .q-btn {
+  padding: 10px 16px;
+}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+  -webkit-appearance: none;
+  -moz-appearance: none;
+  appearance: none;
+  margin: 0;
+}
+
+.q-separator {
+  padding-top: 0px;
+  padding-left: 0;
+}
+
+.q-scrollarea__content {
+  display: flex;
+  flex-direction: column;
+  flex: 1 1 auto !important;
+}

+ 2 - 0
src/helpers/masks.js

@@ -13,6 +13,8 @@ const masks = {
     telefone: "(##) ####-####",
     cep: "#####-###",
     cnpj: "##.###.###/####-##",
+    date: "##/##/####",
+    datetime: "##/##/#### ##:##",
   },
   Paraguay: {
     celular: "(###) ###-###",

+ 2 - 2
src/helpers/utils.js

@@ -22,10 +22,10 @@ const excerpt = (string, size = 30) => {
  * @returns {string} data formatada.
  */
 const formatDateDMYtoYMD = (date, time) => {
-  if (!date) throw new Error(useI18n().t("rules.required"));
+  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("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 : ""}`;

+ 246 - 61
src/i18n/locales/en.json

@@ -1,75 +1,260 @@
 {
-  "general": {
-    "add": "Add",
-    "edit": "Edit",
-    "options": "Options",
-    "welcome": "Welcome",
-    "version": "Version",
-    "all": "All",
-    "active": "Active",
-    "inactive": "Inactive",
-    "confirm_password": "Confirm Password",
-    "search": "Search",
-    "title": "Title",
-    "rows_per_page": "Rows per page",
-    "save": "Save",
-    "cancel": "Cancel"
-  },
-  "errors": {
-    "404": "Page not found",
-    "no_records_found": "No records found",
-    "failed": "Action failed",
-    "success": "Action was successful"
+  "common": {
+    "actions": {
+      "save": "Save",
+      "cancel": "Cancel",
+      "edit": "Edit",
+      "add": "Add",
+      "search": "Search",
+      "delete": "Delete",
+      "view": "View",
+      "back": "Back",
+      "next": "Next",
+      "resend_email": "Resend email",
+      "download_certificate": "Download certificate",
+      "download_boleto": "Download Boleto",
+      "copy_paste_code": "Copy and paste the code below to make the payment"
+    },
+    "terms": {
+      "name": "Name",
+      "email": "Email",
+      "password": "Password",
+      "description": "Description",
+      "date": "Date",
+      "start_date": "Start Date",
+      "end_date": "End Date",
+      "code": "Code",
+      "title": "Title",
+      "status": "Status",
+      "price": "Price",
+      "quantity": "Quantity",
+      "city": "City",
+      "state": "State",
+      "country": "Country",
+      "address": "Address",
+      "address_number": "Address Number",
+      "complement": "Complement",
+      "postal_code": "Postal Code",
+      "phone": "Phone",
+      "document": "Document",
+      "document_type": "Document Type",
+      "cpf": "CPF",
+      "cnpj": "CNPJ",
+      "cep": "ZIP Code",
+      "order_number": "Order Number",
+      "order_amount": "Order Amount",
+      "total_amount": "Total Amount",
+      "payment": "Payment",
+      "payment_method": "Payment Method",
+      "payment_date": "Payment Date",
+      "payment_amount": "Payment Amount",
+      "language": "Language",
+      "currency": "Currency",
+      "interests": "Interests",
+      "avatar": "Avatar",
+      "banner": "Banner",
+      "logo": "Logo",
+      "media": "Media",
+      "certificate": "Certificate",
+      "version": "Version"
+    },
+    "status": {
+      "active": "Active",
+      "inactive": "Inactive",
+      "canceled": "Canceled",
+      "loading": "Please wait...",
+      "yes": "Yes",
+      "no": "No"
+    },
+    "ui": {
+      "file": {
+        "choose": "Choose a file",
+        "click_select": "Click to select a file",
+        "click_select_image": "Click to select an image",
+        "drag": "Drag",
+        "drag_and_drop": "Drag and drop the file here",
+        "drag_here": "Drag the file here",
+        "selected": "File selected"
+      },
+      "table": {
+        "rows_per_page": "Rows per page",
+        "of": "of",
+        "to": "to"
+      },
+      "messages": {
+        "copied_to_clipboard": "Copied to clipboard",
+        "confirm_action": "Are you sure?",
+        "welcome": "Welcome",
+        "enjoy_the_event": "Enjoy the event!"
+      },
+      "misc": {
+        "all": "All",
+        "or": "or",
+        "example": "Example",
+        "options": "Options",
+        "total": "Total",
+        "type": "Tipo"
+      }
+    },
+    "metadata": {
+      "created_at": "Created at",
+      "updated_at": "Updated at",
+      "created_by": "Created by"
+    }
   },
-  "navigation": {
-    "dashboard": "Dashboard",
+  "auth": {
     "login": "Login",
     "logout": "Logout",
-    "exit": "Exit",
     "registration": "Registration",
-    "users": "Users",
-    "perfil": "Profile",
-    "plans": "Plans",
-    "wallet": "Wallet",
+    "confirm_password": "Confirm Password",
+    "agreed_terms": "I agree with the terms",
+    "agreed_privacy": "I agree with the privacy policy"
+  },
+  "business": {
     "advertise": "Advertise",
     "my_advertisements": "My Advertisements",
-    "explore": "Explore",
-    "opportunities": "Opportunities",
-    "interests": "Interests",
     "negotiations": "Negotiations",
-    "expand_menu": "Expand menu",
-    "collapse_menu": "Collapse menu"
+    "opportunities": "Opportunities",
+    "plans": "Plans"
+  },
+  "validation": {
+    "rules": {
+      "required": "This field is required",
+      "email": "This field must be a valid email | These fields must be valid emails",
+      "date": "This field must be a valid date",
+      "min": "This field must have at least",
+      "max": "This field must have at most",
+      "characters": "characters",
+      "password": "Password must have at least 6 characters, one uppercase letter, one lowercase letter and one number",
+      "same_password": "Passwords must match",
+      "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",
+      "value_smaller_than_zero": "Value cannot be less than zero"
+    },
+    "permissions": {
+      "view": "You don't have permission to view this",
+      "create": "You don't have permission to create this",
+      "edit": "You don't have permission to edit this",
+      "delete": "You don't have permission to delete this",
+      "add": "You don't have permission to add this"
+    }
+  },
+  "http": {
+    "errors": {
+      "404": "Page not found",
+      "failed": "The action failed",
+      "no_records_found": "No records found"
+    },
+    "success": "The action was successful"
   },
-  "users": {
-    "user": "{something} user | {something} users",
-    "name": "Name",
-    "name_and_surname": "Name and Surname",
-    "password": "Password",
-    "getUser": "Get User",
-    "createUser": "Create User",
-    "updateUser": "Update User"
+  "events": {
+    "singular": "Event",
+    "plural": "Events",
+    "core": {
+      "basic_information": "Basic Information",
+      "schedule": "Schedule",
+      "opening": "Opening",
+      "total_capacity": "Total Capacity",
+      "unique_code": "Unique Code",
+      "unique_code_hint": "This code is automatically generated",
+      "list_of_allowed_documents": "List of allowed documents"
+    },
+    "tickets": {
+      "singular": "Ticket",
+      "plural": "Tickets",
+      "types_singular": "Ticket Type",
+      "types_plural": "Ticket Types",
+      "event_ticket": "Event Ticket",
+      "event_tickets": "Event Tickets",
+      "event_ticket_types": "Event Ticket Types",
+      "sales_start_date": "Sales Start Date",
+      "sales_end_date": "Sales End Date",
+      "max_per_user": "Maximum Tickets per User",
+      "max_per_user_hint": "0 for unlimited",
+      "quantity_available": "Available Quantity",
+      "quantity_sold": "Quantity Sold"
+    },
+    "location": {
+      "singular": "Location"
+    },
+    "attendance": {
+      "participant_singular": "Participant",
+      "participant_plural": "Participants",
+      "checked_in_at": "Checked in at",
+      "is_checked_in": "Is checked in"
+    }
   },
-  "labels": {
-    "of": "of",
-    "to": "to"
+  "user": {
+    "singular": "User",
+    "plural": "Users",
+    "profile": {
+      "singular": "Profile",
+      "name_and_surname": "Name and Surname",
+      "birth_date": "Birth Date",
+      "personal_information": "Personal Information"
+    },
+    "preferences": {
+      "singular": "Preferences"
+    }
   },
-  "permissions": {
-    "add": "You don't have permission to add this",
-    "view": "You don't have permission to view this",
-    "edit": "You don't have permission to edit this",
-    "delete": "You don't have permission to delete this",
-    "create": "You don't have permission to create this"
+  "orders": {
+    "singular": "Order",
+    "plural": "Orders",
+    "core": {
+      "new_order": "New Order",
+      "payment_received": "Payment Received",
+      "resume": "Order Resume",
+      "buyer_information": "Buyer Information",
+      "participant_information": "Participant Information",
+      "same_as_buyer": "Same as buyer",
+      "select_at_least_one_ticket": "Select at least one ticket",
+      "select_payment_method": "Select a payment method",
+      "exclusive_list": "Exclusive list",
+      "successful_payment": "Successful payment"
+    },
+    "statuses": {
+      "paid": "Paid",
+      "pending": "Pending",
+      "approved": "Approved",
+      "canceled": "Canceled",
+      "completed": "Completed",
+      "confirmed": "Confirmed",
+      "confirmation": "Confirmation"
+    },
+    "payment_methods": {
+      "credit_card": "Credit Card",
+      "boleto": "Boleto",
+      "pix": "Pix"
+    }
   },
-  "rules": {
-    "required": "This field is required",
-    "characters": "characters",
-    "min": "This field must have at least",
-    "max": "This field must have a maximum of",
-    "email": "This field must be a valid email",
-    "date": "This field must be a valid date",
-    "cpf": "This field must be a valid CPF",
-    "cnpj": "This field must be a valid CNPJ",
-    "same_password": "Passwords must match",
-    "password": "The password must be at least 8 characters, one uppercase letter, one lowercase letter, and one number"
+  "ui": {
+    "navigation": {
+      "collapse_menu": "Collapse menu",
+      "expand_menu": "Expand menu",
+      "dashboard": "Dashboard",
+      "explore": "Explore",
+      "advertise": "Advertise",
+      "my_advertisements": "My Advertisements",
+      "negotiations": "Negotiations",
+      "opportunities": "Opportunities",
+      "plans": "Plans",
+      "events": "Events",
+      "event_tickets": "Event Tickets",
+      "event_ticket_types": "Event Ticket Types",
+      "orders": "Orders",
+      "sales": "Sales",
+      "participants": "Participants",
+      "users": "Users",
+      "profile": "Profile",
+      "interests": "Interests",
+      "registration": "Registration",
+      "wallet": "Wallet",
+      "settings": "Settings",
+      "city": "City",
+      "state": "State",
+      "country": "Country",
+      "exit": "Exit"
+    }
   }
 }

+ 248 - 63
src/i18n/locales/es.json

@@ -1,75 +1,260 @@
 {
-  "general": {
-    "add": "Añadir",
-    "edit": "Editar",
-    "options": "Opciones",
-    "welcome": "Bienvenido",
-    "version": "Versión",
-    "all": "Todos",
-    "active": "Activo",
-    "inactive": "Inactivo",
-    "confirm_password": "Confirmar contraseña",
-    "search": "Buscar",
-    "title": "Título",
-    "rows_per_page": "Filas por página",
-    "save": "Guardar",
-    "cancel": "Cancelar"
+  "common": {
+    "actions": {
+      "save": "Guardar",
+      "cancel": "Cancelar",
+      "edit": "Editar",
+      "add": "Añadir",
+      "search": "Buscar",
+      "delete": "Eliminar",
+      "view": "Ver",
+      "back": "Volver",
+      "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"
+    },
+    "terms": {
+      "name": "Nombre",
+      "email": "Correo Electrónico",
+      "password": "Contraseña",
+      "description": "Descripción",
+      "date": "Fecha",
+      "start_date": "Fecha de Inicio",
+      "end_date": "Fecha de Fin",
+      "code": "Código",
+      "title": "Título",
+      "status": "Estado",
+      "price": "Precio",
+      "quantity": "Cantidad",
+      "city": "Ciudad",
+      "state": "Estado/Provincia",
+      "country": "País",
+      "address": "Dirección",
+      "address_number": "Número",
+      "complement": "Complemento",
+      "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",
+      "cep": "Código Postal",
+      "order_number": "Número de Pedido",
+      "order_amount": "Importe del Pedido",
+      "total_amount": "Importe Total",
+      "payment": "Pago",
+      "payment_method": "Método de Pago",
+      "payment_date": "Fecha de Pago",
+      "payment_amount": "Importe del Pago",
+      "language": "Idioma",
+      "currency": "Moneda",
+      "interests": "Intereses",
+      "avatar": "Avatar",
+      "banner": "Banner",
+      "logo": "Logo",
+      "media": "Medios",
+      "certificate": "Certificado",
+      "version": "Versión"
+    },
+    "status": {
+      "active": "Activo",
+      "inactive": "Inactivo",
+      "canceled": "Cancelado",
+      "loading": "Por favor espere...",
+      "yes": "Sí",
+      "no": "No"
+    },
+    "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í",
+        "selected": "Archivo seleccionado"
+      },
+      "table": {
+        "rows_per_page": "Filas por página",
+        "of": "de",
+        "to": "a"
+      },
+      "messages": {
+        "copied_to_clipboard": "Copiado al portapapeles",
+        "confirm_action": "¿Estás seguro?",
+        "welcome": "Bienvenido(a)",
+        "enjoy_the_event": "¡Disfruta del evento!"
+      },
+      "misc": {
+        "all": "Todos",
+        "or": "o",
+        "example": "Ejemplo",
+        "options": "Opciones",
+        "total": "Total",
+        "type": "Tipo"
+      }
+    },
+    "metadata": {
+      "created_at": "Creado el",
+      "updated_at": "Actualizado el",
+      "created_by": "Creado por"
+    }
   },
-  "errors": {
-    "404": "Página no encontrada",
-    "no_records_found": "No se encontraron registros",
-    "failed": "La acción falló",
-    "success": "La acción fue exitosa"
-  },
-  "navigation": {
-    "dashboard": "Tablero",
-    "login": "Iniciar sesión",
-    "logout": "Salir",
-    "exit": "Salir",
+  "auth": {
+    "login": "Iniciar Sesión",
+    "logout": "Cerrar Sesión",
     "registration": "Registro",
-    "users": "Usuarios",
-    "perfil": "Perfil",
-    "plans": "Planes",
-    "wallet": "Cartera",
+    "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",
-    "explore": "Explorar",
-    "opportunities": "Oportunidades",
-    "interests": "Intereses",
     "negotiations": "Negociaciones",
-    "expand_menu": "Expandir menu",
-    "collapse_menu": "Colapsar menu"
+    "opportunities": "Oportunidades",
+    "plans": "Planes"
+  },
+  "validation": {
+    "rules": {
+      "required": "Este campo es obligatorio",
+      "email": "Este campo debe ser un correo electrónico válido | Estos campos deben ser correos electrónicos válidos",
+      "date": "Este campo debe ser una fecha válida",
+      "min": "Este campo debe tener al menos",
+      "max": "Este campo debe tener como máximo",
+      "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",
+      "value_smaller_than_zero": "El valor no puede ser menor que cero"
+    },
+    "permissions": {
+      "view": "No tienes permiso para ver esto",
+      "create": "No tienes permiso para crear esto",
+      "edit": "No tienes permiso para editar esto",
+      "delete": "No tienes permiso para eliminar esto",
+      "add": "No tienes permiso para añadir esto"
+    }
+  },
+  "http": {
+    "errors": {
+      "404": "Página no encontrada",
+      "failed": "La acción falló",
+      "no_records_found": "No se encontraron registros"
+    },
+    "success": "La acción se realizó con éxito"
   },
-  "users": {
-    "user": "{something} usuario | {something} usuarios",
-    "name": "Nombre",
-    "name_and_surname": "Nombre y apellido",
-    "password": "Contraseña",
-    "getUser": "Obtener usuario",
-    "createUser": "Crear usuario",
-    "updateUser": "Actualizar usuario"
+  "events": {
+    "singular": "Evento",
+    "plural": "Eventos",
+    "core": {
+      "basic_information": "Información Básica",
+      "schedule": "Programa",
+      "opening": "Apertura",
+      "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"
+    },
+    "tickets": {
+      "singular": "Entrada",
+      "plural": "Entradas",
+      "types_singular": "Tipo de Entrada",
+      "types_plural": "Tipos de Entrada",
+      "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",
+      "max_per_user_hint": "0 para ilimitado",
+      "quantity_available": "Cantidad Disponible",
+      "quantity_sold": "Cantidad Vendida"
+    },
+    "location": {
+      "singular": "Ubicación"
+    },
+    "attendance": {
+      "participant_singular": "Participante",
+      "participant_plural": "Participantes",
+      "checked_in_at": "Check-in realizado el",
+      "is_checked_in": "Check-in realizado"
+    }
   },
-  "labels": {
-    "of": "de",
-    "to": "a"
+  "user": {
+    "singular": "Usuario",
+    "plural": "Usuarios",
+    "profile": {
+      "singular": "Perfil",
+      "name_and_surname": "Nombre y Apellido",
+      "birth_date": "Fecha de Nacimiento",
+      "personal_information": "Información Personal"
+    },
+    "preferences": {
+      "singular": "Preferencias"
+    }
   },
-  "permissions": {
-    "add": "No tienes permiso para agregar esto",
-    "view": "No tienes permiso para ver esto",
-    "edit": "No tienes permiso para editar esto",
-    "delete": "No tienes permiso para eliminar esto",
-    "create": "No tienes permiso para crear esto"
+  "orders": {
+    "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",
+      "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",
+      "exclusive_list": "Lista exclusiva",
+      "successful_payment": "Pago realizado con éxito"
+    },
+    "statuses": {
+      "paid": "Pagado",
+      "pending": "Pendiente",
+      "approved": "Aprobado",
+      "canceled": "Cancelado",
+      "completed": "Completado",
+      "confirmed": "Confirmado",
+      "confirmation": "Confirmación"
+    },
+    "payment_methods": {
+      "credit_card": "Tarjeta de Crédito",
+      "boleto": "Boleto/Recibo",
+      "pix": "Pix/Transferencia Instantánea"
+    }
   },
-  "rules": {
-    "required": "Este campo es obligatorio",
-    "characters": "caracteres",
-    "min": "Este campo debe tener al menos",
-    "max": "Este campo debe tener como máximo",
-    "email": "Este campo debe ser un correo electrónico válido",
-    "date": "Este campo debe ser una fecha válida",
-    "cpf": "Este campo debe ser un CPF válido",
-    "cnpj": "Este campo debe ser un CNPJ válido",
-    "same_password": "Las contraseñas deben coincidir",
-    "password": "La contraseña debe tener al menos 8 caracteres, una letra mayúscula, una letra minúscula y un número"
+  "ui": {
+    "navigation": {
+      "collapse_menu": "Contraer menú",
+      "expand_menu": "Expandir menú",
+      "dashboard": "Panel",
+      "explore": "Explorar",
+      "advertise": "Anunciar",
+      "my_advertisements": "Mis Anuncios",
+      "negotiations": "Negociaciones",
+      "opportunities": "Oportunidades",
+      "plans": "Planes",
+      "events": "Eventos",
+      "event_tickets": "Entradas",
+      "event_ticket_types": "Tipos de Entrada",
+      "orders": "Pedidos",
+      "sales": "Ventas",
+      "participants": "Participantes",
+      "users": "Usuarios",
+      "profile": "Perfil",
+      "interests": "Intereses",
+      "registration": "Registro",
+      "wallet": "Cartera",
+      "settings": "Configuración",
+      "city": "Ciudad",
+      "state": "Estado/Provincia",
+      "country": "País",
+      "exit": "Salir"
+    }
   }
 }

+ 248 - 63
src/i18n/locales/pt.json

@@ -1,75 +1,260 @@
 {
-  "general": {
-    "add": "Adicionar",
-    "edit": "Editar",
-    "options": "Opções",
-    "welcome": "Bem-vindo",
-    "version": "Versão",
-    "all": "Todos",
-    "active": "Ativo",
-    "inactive": "Inativo",
-    "confirm_password": "Confirmar senha",
-    "search": "Buscar",
-    "title": "Título",
-    "rows_per_page": "Linhas por página",
-    "save": "Salvar",
-    "cancel": "Cancelar"
+  "common": {
+    "actions": {
+      "save": "Salvar",
+      "cancel": "Cancelar",
+      "edit": "Editar",
+      "add": "Adicionar",
+      "search": "Buscar",
+      "delete": "Excluir",
+      "view": "Visualizar",
+      "back": "Voltar",
+      "next": "Próximo",
+      "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"
+    },
+    "terms": {
+      "name": "Nome",
+      "email": "E-mail",
+      "password": "Senha",
+      "description": "Descrição",
+      "date": "Data",
+      "start_date": "Data de Início",
+      "end_date": "Data de Fim",
+      "code": "Código",
+      "title": "Título",
+      "status": "Status",
+      "price": "Preço",
+      "quantity": "Quantidade",
+      "city": "Cidade",
+      "state": "Estado",
+      "country": "País",
+      "address": "Endereço",
+      "address_number": "Número",
+      "complement": "Complemento",
+      "postal_code": "CEP",
+      "phone": "Telefone",
+      "document": "Documento",
+      "document_type": "Tipo de Documento",
+      "cpf": "CPF",
+      "cnpj": "CNPJ",
+      "cep": "CEP",
+      "order_number": "Número do Pedido",
+      "order_amount": "Valor do Pedido",
+      "total_amount": "Valor Total",
+      "payment": "Pagamento",
+      "payment_method": "Método de Pagamento",
+      "payment_date": "Data do Pagamento",
+      "payment_amount": "Valor do Pagamento",
+      "language": "Idioma",
+      "currency": "Moeda",
+      "interests": "Interesses",
+      "avatar": "Avatar",
+      "banner": "Banner",
+      "logo": "Logo",
+      "media": "Mídia",
+      "certificate": "Certificado",
+      "version": "Versão"
+    },
+    "status": {
+      "active": "Ativo",
+      "inactive": "Inativo",
+      "canceled": "Cancelado",
+      "loading": "Aguarde...",
+      "yes": "Sim",
+      "no": "Não"
+    },
+    "ui": {
+      "file": {
+        "choose": "Escolha um arquivo",
+        "click_select": "Clique para selecionar um arquivo",
+        "click_select_image": "Clique para selecionar uma imagem",
+        "drag": "Arraste",
+        "drag_and_drop": "Arraste e solte o arquivo aqui",
+        "drag_here": "Arraste o arquivo aqui",
+        "selected": "Arquivo selecionado"
+      },
+      "table": {
+        "rows_per_page": "Linhas por página",
+        "of": "de",
+        "to": "a"
+      },
+      "messages": {
+        "copied_to_clipboard": "Copiado para a área de transferência",
+        "confirm_action": "Você tem certeza?",
+        "welcome": "Bem-vindo(a)",
+        "enjoy_the_event": "Aproveite o evento!"
+      },
+      "misc": {
+        "all": "Todos",
+        "or": "ou",
+        "example": "Exemplo",
+        "options": "Opções",
+        "total": "Total",
+        "type": "Tipo"
+      }
+    },
+    "metadata": {
+      "created_at": "Criado em",
+      "updated_at": "Atualizado em",
+      "created_by": "Criado por"
+    }
   },
-  "errors": {
-    "404": "Página não encontrada",
-    "no_records_found": "Nenhum registro encontrado",
-    "failed": "A ação falhou",
-    "success": "A ação foi bem sucedida"
-  },
-  "navigation": {
-    "dashboard": "Dashboard",
+  "auth": {
     "login": "Login",
     "logout": "Sair",
-    "exit": "Sair",
-    "registration": "Registro",
-    "users": "Usuários",
-    "perfil": "Perfil",
-    "plans": "Planos",
-    "wallet": "Carteira",
-    "advertise": "Anúnciar",
+    "registration": "Cadastro",
+    "confirm_password": "Confirmar Senha",
+    "agreed_terms": "Concordo com os termos",
+    "agreed_privacy": "Concordo com a política de privacidade"
+  },
+  "business": {
+    "advertise": "Anunciar",
     "my_advertisements": "Meus Anúncios",
-    "explore": "Explorar",
-    "opportunities": "Oportunidades",
-    "interests": "Interesses",
     "negotiations": "Negociações",
-    "expand_menu": "Expandir menu",
-    "collapse_menu": "Colapsar menu"
+    "opportunities": "Oportunidades",
+    "plans": "Planos"
+  },
+  "validation": {
+    "rules": {
+      "required": "Este campo é obrigatório",
+      "email": "Este campo deve ser um e-mail válido | Estes campos devem ser e-mails válidos",
+      "date": "Este campo deve ser uma data válida",
+      "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",
+      "same_password": "As senhas devem ser iguais",
+      "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",
+      "value_smaller_than_zero": "O valor não pode ser menor que zero"
+    },
+    "permissions": {
+      "view": "Você não tem permissão para visualizar isto",
+      "create": "Você não tem permissão para criar isto",
+      "edit": "Você não tem permissão para editar isto",
+      "delete": "Você não tem permissão para excluir isto",
+      "add": "Você não tem permissão para adicionar isto"
+    }
+  },
+  "http": {
+    "errors": {
+      "404": "Página não encontrada",
+      "failed": "A ação falhou",
+      "no_records_found": "Nenhum registro encontrado"
+    },
+    "success": "A ação foi bem-sucedida"
   },
-  "users": {
-    "user": "{something} usuario | {something} usuarios",
-    "name_and_surname": "Nome e sobrenome",
-    "name": "Nome",
-    "password": "Senha",
-    "getUser": "Obter usuário",
-    "createUser": "Criar usuário",
-    "updateUser": "Atualizar usuário"
+  "events": {
+    "singular": "Evento",
+    "plural": "Eventos",
+    "core": {
+      "basic_information": "Informações Básicas",
+      "schedule": "Programação",
+      "opening": "Abertura",
+      "total_capacity": "Capacidade Total",
+      "unique_code": "Código Único",
+      "unique_code_hint": "Este código é gerado automaticamente",
+      "list_of_allowed_documents": "Lista de documentos permitidos"
+    },
+    "tickets": {
+      "singular": "Ingresso",
+      "plural": "Ingressos",
+      "types_singular": "Tipo de Ingresso",
+      "types_plural": "Tipos de Ingresso",
+      "event_ticket": "Ingresso do Evento",
+      "event_tickets": "Ingressos do Evento",
+      "event_ticket_types": "Tipos de Ingresso do Evento",
+      "sales_start_date": "Data de Início das Vendas",
+      "sales_end_date": "Data de Fim das Vendas",
+      "max_per_user": "Máximo de Ingressos por Usuário",
+      "max_per_user_hint": "0 para ilimitado",
+      "quantity_available": "Quantidade Disponível",
+      "quantity_sold": "Quantidade Vendida"
+    },
+    "location": {
+      "singular": "Localização"
+    },
+    "attendance": {
+      "participant_singular": "Participante",
+      "participant_plural": "Participantes",
+      "checked_in_at": "Check-in realizado em",
+      "is_checked_in": "Check-in realizado"
+    }
   },
-  "labels": {
-    "of": "de",
-    "to": "de"
+  "user": {
+    "singular": "Usuário",
+    "plural": "Usuários",
+    "profile": {
+      "singular": "Perfil",
+      "name_and_surname": "Nome e Sobrenome",
+      "birth_date": "Data de Nascimento",
+      "personal_information": "Informações Pessoais"
+    },
+    "preferences": {
+      "singular": "Preferências"
+    }
   },
-  "permissions": {
-    "add": "Você não tem permissão para adicionar isso",
-    "view": "Você não tem permissão para visualizar isso",
-    "edit": "Você não tem permissão para editar isso",
-    "delete": "Você não tem permissão para excluir isso",
-    "create": "Você não tem permissão para criar isso"
+  "orders": {
+    "singular": "Pedido",
+    "plural": "Pedidos",
+    "core": {
+      "new_order": "Novo Pedido",
+      "payment_received": "Pagamento Recebido",
+      "resume": "Resumo do Pedido",
+      "buyer_information": "Informações do Comprador",
+      "participant_information": "Informações do Participante",
+      "same_as_buyer": "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"
+    },
+    "statuses": {
+      "paid": "Pago",
+      "pending": "Pendente",
+      "approved": "Aprovado",
+      "canceled": "Cancelado",
+      "completed": "Concluído",
+      "confirmed": "Confirmado",
+      "confirmation": "Confirmação"
+    },
+    "payment_methods": {
+      "credit_card": "Cartão de Crédito",
+      "boleto": "Boleto",
+      "pix": "Pix"
+    }
   },
-  "rules": {
-    "required": "Este campo é obrigatório",
-    "characters": "caracteres",
-    "min": "Este campo deve ter pelo menos",
-    "max": "Este campo deve ter no máximo",
-    "email": "Este campo deve ser um email válido | Estes campos devem ser emails válidos",
-    "date": "Este campo deve ser uma data válida",
-    "cpf": "Este campo deve ser um CPF válido",
-    "cnpj": "Este campo deve ser um CNPJ válido",
-    "same_password": "As senhas devem ser iguais",
-    "password": "A senha deve ter pelo menos 6 caracteres, uma letra maiúscula, uma letra minúscula e um número"
+  "ui": {
+    "navigation": {
+      "collapse_menu": "Recolher menu",
+      "expand_menu": "Expandir menu",
+      "dashboard": "Painel",
+      "explore": "Explorar",
+      "advertise": "Anunciar",
+      "my_advertisements": "Meus Anúncios",
+      "negotiations": "Negociações",
+      "opportunities": "Oportunidades",
+      "plans": "Planos",
+      "events": "Eventos",
+      "event_tickets": "Ingressos",
+      "event_ticket_types": "Tipos de Ingresso",
+      "orders": "Pedidos",
+      "sales": "Vendas",
+      "participants": "Participantes",
+      "users": "Usuários",
+      "profile": "Perfil",
+      "interests": "Interesses",
+      "registration": "Cadastro",
+      "wallet": "Carteira",
+      "settings": "Configurações",
+      "city": "Cidade",
+      "state": "Estado",
+      "country": "País",
+      "exit": "Sair"
+    }
   }
 }

+ 3 - 3
src/layouts/MainLayout.vue

@@ -34,7 +34,7 @@
                       style="font-size: 18px"
                     />
                   </q-item-section>
-                  <q-item-section>{{ $t("navigation.perfil") }}</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 +46,7 @@
                       style="font-size: 18px"
                     />
                   </q-item-section>
-                  <q-item-section>{{ $t("navigation.logout") }}</q-item-section>
+                  <q-item-section>{{ $t('auth.logout') }}</q-item-section>
                 </div>
               </q-item>
             </q-list>
@@ -95,7 +95,7 @@ defineOptions({
 
 const { logout } = useAuth();
 const route = useRoute();
-const leftDrawerOpen = ref(true);
+const leftDrawerOpen = ref(false);
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
 const router = useRouter();
 

+ 1 - 1
src/pages/ErrorNotFound.vue

@@ -7,7 +7,7 @@
       <div style="font-size: 30vh">{{ "404" }}</div>
 
       <div class="text-h2" style="opacity: 0.4">
-        {{ $t("errors.404") }}
+        {{ $t("http.errors.404") }}
       </div>
 
       <q-btn

+ 5 - 18
src/pages/LoginPage.vue

@@ -3,7 +3,7 @@
     <q-card flat class="login-card q-pa-md q-pt-xl bg-surface">
       <div class="text-center">
         <q-img :src="Logo" style="max-width: 250px" />
-        <div class="text-h6">{{ $t("general.welcome") }}</div>
+        <div class="text-h6">{{ $t("common.ui.messages.welcome") }}</div>
       </div>
 
       <q-form
@@ -26,23 +26,10 @@
             :rules="[inputRules.required, inputRules.email]"
           />
 
-          <q-input
+          <DefaultPasswordInput
             v-model="password"
-            :label="$t('users.password')"
-            filled
-            :type="isPwd ? 'password' : 'text'"
-            class="q-mt-xs"
-            lazy-rules
             :rules="[inputRules.required, inputRules.min(6)]"
-          >
-            <template #append>
-              <q-icon
-                :name="isPwd ? 'mdi-eye-off' : 'mdi-eye'"
-                class="cursor-pointer q-ml-md"
-                @click="isPwd = !isPwd"
-              />
-            </template>
-          </q-input>
+          />
 
           <q-checkbox v-model="checkbox" label="Lembrar email" />
         </q-card-section>
@@ -50,7 +37,7 @@
         <q-card-actions align="right">
           <q-btn
             color="primary"
-            :label="$t('navigation.login')"
+            :label="$t('auth.login')"
             size="md"
             padding="md"
             type="submit"
@@ -74,6 +61,7 @@ import { useRouter } from "vue-router";
 import { useInputRules } from "src/composables/useInputRules";
 
 import Logo from "src/assets/logo.png";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 
 const router = useRouter();
 const $q = useQuasar();
@@ -81,7 +69,6 @@ const $q = useQuasar();
 const { inputRules } = useInputRules();
 const email = ref("");
 const password = ref(process.env.PASSWORD);
-const isPwd = ref(true);
 const submitting = ref(false);
 const loginForm = ref(null);
 const checkbox = ref(false);

+ 130 - 0
src/pages/city/CityPage.vue

@@ -0,0 +1,130 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+    <div>
+      <DefaultTable
+        ref="tableRef"
+        :columns="columns"
+        :api-call="getCities"
+        :delete-function="deleteCity"
+        :mostrar-selecao-de-colunas="false"
+        :mostrar-botao-fullscreen="false"
+        :mostrar-toggle-inativos="false"
+        open-item
+        add-item
+        @on-row-click="onRowClick"
+        @on-add-item="onAddItem"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+import { defineAsyncComponent, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { getCities, deleteCity } from "src/api/city";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const AddEditCityDialog = defineAsyncComponent(
+  () => import("src/pages/city/components/AddEditCityDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const tableRef = useTemplateRef("tableRef");
+const { t } = useI18n();
+
+const columns = [
+  {
+    name: "id",
+    label: "ID",
+    field: "id",
+    align: "left",
+    required: true,
+    sortable: true,
+  },
+  {
+    name: "nome",
+    label: t("common.terms.name"),
+    field: "name",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "state",
+    label: t("ui.navigation.state"),
+    field: (row) => row.state.name,
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "country",
+    label: t("ui.navigation.country"),
+    field: (row) => row.country.name,
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "status",
+    label: t("common.terms.status"),
+    field: (row) =>
+      row.status == "ACTIVE" ? t("common.status.active") : t("common.status.inactive"),
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "actions",
+    required: true,
+  },
+];
+
+const onRowClick = ({ row }) => {
+  if (permission_store.getAccess("config.city", "edit") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.edit"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditCityDialog,
+    componentProps: {
+      city: row,
+      title: () =>
+        useI18n().t("common.actions.edit") +
+        " " +
+        useI18n().t("ui.navigation.city"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+
+const onAddItem = () => {
+  if (permission_store.getAccess("config.city", "add") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.add"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditCityDialog,
+    componentProps: {
+      title: () =>
+        useI18n().t("common.actions.add") + " " + useI18n().t("ui.navigation.city"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+</script>

+ 158 - 0
src/pages/city/components/AddEditCityDialog.vue

@@ -0,0 +1,158 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <q-input
+            v-model="form.name"
+            :label="$t('common.terms.name')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+          <CountrySelect
+            ref="countrySelectRef"
+            v-model="selectedCountry"
+            :label="$t('ui.navigation.country')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+          <StateSelect
+            v-model="selectedState"
+            :country="selectedCountry"
+            :label="$t('ui.navigation.state')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+            @selected-country-id="countrySelectRef.selectCountryById($event)"
+          />
+          <q-select
+            v-model="selectedStatus"
+            :label="$t('common.terms.status')"
+            :options="statusOptions"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+        </q-card-section>
+        <q-card-actions align="center">
+          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+          <q-space />
+          <q-btn
+            color="primary"
+            label="OK"
+            :type="'submit'"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+<script setup>
+import { ref, useTemplateRef, onMounted, watch } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createCity, updateCity } from "src/api/city";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import CountrySelect from "src/components/regions/CountrySelect.vue";
+import StateSelect from "src/components/regions/StateSelect.vue";
+
+defineEmits([
+  // REQUIRED; need to specify some events that your
+  // component will emit through useDialogPluginComponent()
+  ...useDialogPluginComponent.emits,
+]);
+
+const { city, title } = defineProps({
+  city: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("common.terms.title"),
+  },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = useTemplateRef("formRef");
+const countrySelectRef = useTemplateRef("countrySelectRef");
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name: city ? city.name : "",
+  country_id: city ? city.country_id : null,
+  state_id: city ? city.state_id : null,
+  status: city ? city.status : "ACTIVE",
+});
+
+const loading = ref(false);
+
+const selectedCountry = ref(null);
+const selectedState = ref(null);
+const selectedStatus = ref({
+  label: t("common.status.active"),
+  value: "ACTIVE",
+});
+const statusOptions = ref([
+  { label: t("common.status.active"), value: "ACTIVE" },
+  { label: t("common.status.inactive"), value: "INACTIVE" },
+]);
+
+const onOKClick = async () => {
+  if (!(await formRef.value.validate())) {
+    return;
+  }
+
+  if (city) {
+    loading.value = true;
+    try {
+      await updateCity(city.id, getUpdatedFields.value);
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
+  } else {
+    loading.value = true;
+    try {
+      await createCity({ ...form });
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
+  }
+};
+
+watch(selectedStatus, () => {
+  form.status = selectedStatus.value?.value;
+});
+
+watch(selectedCountry, () => {
+  form.country_id = selectedCountry.value?.value;
+});
+
+watch(selectedState, () => {
+  form.state_id = selectedState.value?.value;
+});
+
+onMounted(async () => {
+  if (city) {
+    selectedStatus.value = statusOptions.value.find(
+      (status) => status.value === city.status,
+    );
+  }
+});
+</script>

+ 125 - 0
src/pages/country/CountryPage.vue

@@ -0,0 +1,125 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+    <div>
+      <DefaultTable
+        ref="tableRef"
+        :columns="columns"
+        :api-call="getCountries"
+        :delete-function="deleteCountry"
+        :mostrar-selecao-de-colunas="false"
+        :mostrar-botao-fullscreen="false"
+        :mostrar-toggle-inativos="false"
+        open-item
+        add-item
+        @on-row-click="onRowClick"
+        @on-add-item="onAddItem"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+import { defineAsyncComponent, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { getCountries, deleteCountry } from "src/api/country";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const AddEditCountryDialog = defineAsyncComponent(
+  () => import("src/pages/country/components/AddEditCountryDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const tableRef = useTemplateRef("tableRef");
+const { t } = useI18n();
+
+const columns = [
+  {
+    name: "id",
+    label: "ID",
+    field: "id",
+    align: "left",
+    required: true,
+    sortable: true,
+  },
+  {
+    name: "nome",
+    label: t("common.terms.name"),
+    field: "name",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "code",
+    label: t("common.terms.code"),
+    field: "code",
+    align: "center",
+    sortable: true,
+  },
+  {
+    name: "status",
+    label: t("common.terms.status"),
+    field: (row) =>
+      row.status == "ACTIVE" ? t("common.status.active") : t("common.status.inactive"),
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "actions",
+    required: true,
+  },
+];
+
+const onRowClick = ({ row }) => {
+  if (permission_store.getAccess("config.country", "edit") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.edit"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditCountryDialog,
+    componentProps: {
+      country: row,
+      title: () =>
+        useI18n().t("common.actions.edit") +
+        " " +
+        useI18n().t("ui.navigation.country"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+
+const onAddItem = () => {
+  if (permission_store.getAccess("config.country", "add") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.add"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditCountryDialog,
+    componentProps: {
+      title: () =>
+        useI18n().t("common.actions.add") +
+        " " +
+        useI18n().t("ui.navigation.country"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+</script>

+ 137 - 0
src/pages/country/components/AddEditCountryDialog.vue

@@ -0,0 +1,137 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <q-input
+            v-model="form.name"
+            :label="$t('common.terms.name')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+          <q-input
+            v-model="form.code"
+            :label="$t('common.terms.code')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+          <q-select
+            v-model="selectedStatus"
+            :label="$t('common.terms.status')"
+            :options="statusOptions"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+        </q-card-section>
+        <q-card-actions align="center">
+          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+          <q-space />
+          <q-btn
+            color="primary"
+            label="OK"
+            :type="'submit'"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+<script setup>
+import { ref, useTemplateRef, onMounted, watch } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createCountry, updateCountry } from "src/api/country";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([
+  // REQUIRED; need to specify some events that your
+  // component will emit through useDialogPluginComponent()
+  ...useDialogPluginComponent.emits,
+]);
+
+const { country, title } = defineProps({
+  country: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("common.terms.title"),
+  },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = useTemplateRef("formRef");
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name: country ? country.name : "",
+  code: country ? country.code : "",
+  status: country ? country.status : "ACTIVE",
+});
+
+const loading = ref(false);
+
+const selectedStatus = ref({
+  label: t("common.status.active"),
+  value: "ACTIVE",
+});
+const statusOptions = ref([
+  { label: t("common.status.active"), value: "ACTIVE" },
+  { label: t("common.status.inactive"), value: "INACTIVE" },
+]);
+
+const onOKClick = async () => {
+  if (!(await formRef.value.validate())) {
+    return;
+  }
+
+  if (country) {
+    // When editing, only send changed fields
+    loading.value = true;
+    try {
+      await updateCountry(country.id, getUpdatedFields.value);
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
+  } else {
+    // When creating, send all fields
+    loading.value = true;
+    try {
+      await createCountry({ ...form });
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
+  }
+};
+
+watch(selectedStatus, () => {
+  form.status = selectedStatus.value.value;
+});
+
+onMounted(() => {
+  if (country) {
+    selectedStatus.value = statusOptions.value.find(
+      (status) => status.value === country.status,
+    );
+  }
+});
+</script>

+ 125 - 0
src/pages/state/StatePage.vue

@@ -0,0 +1,125 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+    <div>
+      <DefaultTable
+        ref="tableRef"
+        :columns="columns"
+        :api-call="getStates"
+        :delete-function="deleteState"
+        :mostrar-selecao-de-colunas="false"
+        :mostrar-botao-fullscreen="false"
+        :mostrar-toggle-inativos="false"
+        open-item
+        add-item
+        @on-row-click="onRowClick"
+        @on-add-item="onAddItem"
+      />
+    </div>
+  </div>
+</template>
+<script setup>
+import { defineAsyncComponent, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { getStates, deleteState } from "src/api/state";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const AddEditStateDialog = defineAsyncComponent(
+  () => import("src/pages/state/components/AddEditStateDialog.vue"),
+);
+
+const permission_store = permissionStore();
+const $q = useQuasar();
+const tableRef = useTemplateRef("tableRef");
+const { t } = useI18n();
+
+const columns = [
+  {
+    name: "id",
+    label: "ID",
+    field: "id",
+    align: "left",
+    required: true,
+    sortable: true,
+  },
+  {
+    name: "nome",
+    label: t("common.terms.name"),
+    field: "name",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "country",
+    label: t("ui.navigation.country"),
+    field: (row) => row.country.name,
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "status",
+    label: t("common.terms.status"),
+    field: (row) =>
+      row.status == "ACTIVE" ? t("common.status.active") : t("common.status.inactive"),
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "actions",
+    required: true,
+  },
+];
+
+const onRowClick = ({ row }) => {
+  if (permission_store.getAccess("config.state", "edit") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.edit"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditStateDialog,
+    componentProps: {
+      state: row,
+      title: () =>
+        useI18n().t("common.actions.edit") +
+        " " +
+        useI18n().t("ui.navigation.state"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+
+const onAddItem = () => {
+  if (permission_store.getAccess("config.state", "add") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.add"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditStateDialog,
+    componentProps: {
+      title: () =>
+        useI18n().t("common.actions.add") +
+        " " +
+        useI18n().t("ui.navigation.state"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+</script>

+ 150 - 0
src/pages/state/components/AddEditStateDialog.vue

@@ -0,0 +1,150 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <q-input
+            v-model="form.name"
+            :label="$t('common.terms.name')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+          <q-input
+            v-model="form.code"
+            :label="$t('common.terms.code')"
+            :rules="[inputRules.required, inputRules.max(2)]"
+            class="col-md-6 col-12"
+          />
+          <CountrySelect
+            v-model="selectedCountry"
+            :label="$t('ui.navigation.country')"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+          <q-select
+            v-model="selectedStatus"
+            :label="$t('common.terms.status')"
+            :options="statusOptions"
+            :rules="[inputRules.required]"
+            class="col-md-6 col-12"
+          />
+        </q-card-section>
+        <q-card-actions align="center">
+          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+          <q-space />
+          <q-btn
+            color="primary"
+            label="OK"
+            :type="'submit'"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+<script setup>
+import { ref, useTemplateRef, onMounted, watch } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createState, updateState } from "src/api/state";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import CountrySelect from "src/components/regions/CountrySelect.vue";
+
+defineEmits([
+  // REQUIRED; need to specify some events that your
+  // component will emit through useDialogPluginComponent()
+  ...useDialogPluginComponent.emits,
+]);
+
+const { state, title } = defineProps({
+  state: {
+    type: Object,
+    default: null,
+  },
+  title: {
+    type: Function,
+    default: () => useI18n().t("common.terms.title"),
+  },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const formRef = useTemplateRef("formRef");
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name: state ? state.name : "",
+  code: state ? state.code : "",
+  country_id: state ? state.country_id : null,
+  status: state ? state.status : "ACTIVE",
+});
+
+const loading = ref(false);
+
+const selectedCountry = ref(null);
+const selectedStatus = ref({
+  label: t("common.status.active"),
+  value: "ACTIVE",
+});
+const statusOptions = ref([
+  { label: t("common.status.active"), value: "ACTIVE" },
+  { label: t("common.status.inactive"), value: "INACTIVE" },
+]);
+
+const onOKClick = async () => {
+  if (!(await formRef.value.validate())) {
+    return;
+  }
+
+  if (state) {
+    // When editing, only send changed fields
+    loading.value = true;
+    try {
+      await updateState(state.id, getUpdatedFields.value);
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
+  } else {
+    // When creating, send all fields
+    loading.value = true;
+    try {
+      await createState({ ...form });
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
+  }
+};
+
+watch(selectedCountry, () => {
+  form.country_id = selectedCountry.value.value;
+});
+
+watch(selectedStatus, () => {
+  form.status = selectedStatus.value.value;
+});
+
+onMounted(async () => {
+  if (state) {
+    selectedStatus.value = statusOptions.value.find(
+      (status) => status.value === state.status,
+    );
+  }
+});
+</script>

+ 23 - 20
src/pages/users/UsersPage.vue

@@ -3,9 +3,10 @@
     <DefaultHeaderPage />
     <div>
       <DefaultTable
-        :key="tableKey"
+        ref="tableRef"
         :columns="columns"
         :api-call="getUsers"
+        :delete-function="deleteUser"
         :mostrar-selecao-de-colunas="false"
         :mostrar-botao-fullscreen="false"
         :mostrar-toggle-inativos="false"
@@ -19,13 +20,13 @@
 </template>
 
 <script setup>
-import { ref, defineAsyncComponent } from "vue";
+import { defineAsyncComponent, useTemplateRef } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import { permissionStore } from "src/stores/permission";
-import { getUsers, createUser, updateUser } from "src/api/user";
+import { getUsers, deleteUser } from "src/api/user";
 
-import DefaultTable from "src/components/geral/DefaultTable.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 
 const AddEditUserDialog = defineAsyncComponent(
@@ -34,16 +35,15 @@ const AddEditUserDialog = defineAsyncComponent(
 
 const permission_store = permissionStore();
 const $q = useQuasar();
-const tableKey = ref(0);
 const { t } = useI18n();
+const tableRef = useTemplateRef("tableRef");
 
 const columns = [
   {
     name: "nome",
-    label: t("users.name"),
+    label: t("common.terms.name"),
     field: "name",
     align: "left",
-    style: "width: 50%",
     required: true,
     sortable: true,
   },
@@ -52,11 +52,12 @@ const columns = [
     label: "Email",
     field: "email",
     align: "left",
-    style: "width: 20%",
-    required: true,
     sortable: true,
   },
-  {},
+  {
+    name: "actions",
+    required: true,
+  },
 ];
 
 const onRowClick = ({ row }) => {
@@ -64,7 +65,7 @@ const onRowClick = ({ row }) => {
     $q.loading.hide();
     $q.notify({
       type: "negative",
-      message: t("permissions.view"),
+      message: t("validation.permissions.view"),
     });
     return;
   }
@@ -73,11 +74,12 @@ const onRowClick = ({ row }) => {
     componentProps: {
       user: row,
       title: () =>
-        useI18n().t("users.user", { something: useI18n().t("general.edit") }),
+        useI18n().t("common.actions.edit") + " " + useI18n().t("user.singular"),
     },
-  }).onOk(async (payload) => {
-    await updateUser(payload, row.id);
-    tableKey.value = tableKey.value + 1;
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
   });
 };
 
@@ -86,7 +88,7 @@ const onAddItem = () => {
     $q.loading.hide();
     $q.notify({
       type: "negative",
-      message: t("permissions.add"),
+      message: t("validation.permissions.add"),
     });
     return;
   }
@@ -95,11 +97,12 @@ const onAddItem = () => {
 
     componentProps: {
       title: () =>
-        useI18n().t("users.user", { something: useI18n().t("general.add") }),
+        useI18n().t("common.actions.add") + " " + useI18n().t("user.singular"),
     },
-  }).onOk(async (payload) => {
-    await createUser(payload);
-    tableKey.value = tableKey.value + 1;
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
   });
 };
 </script>

+ 58 - 37
src/pages/users/components/AddEditUserDialog.vue

@@ -1,27 +1,32 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin">
-      <DefaultDialogHeader :title="props.title" @close="onDialogCancel" />
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
       <q-form ref="formRef" @submit="onOKClick">
         <q-card-section class="row q-col-gutter-sm">
           <q-input
             v-model="form.name"
-            :label="$t('users.name')"
-            :hint="$t('users.name_and_surname')"
+            :label="$t('common.terms.name')"
+            :hint="$t('user.profile.name_and_surname')"
             :rules="[inputRules.required]"
-            class="col-6"
+            class="col-md-6 col-12"
           />
           <q-input
             v-model="form.email"
             label="Email"
             :rules="[inputRules.email]"
-            class="col-6"
+            class="col-md-6 col-12"
           />
-          <q-input
+          <UserTypeSelect
+            v-model="selectedUserType"
+            :rules="[inputRules.required]"
+            :type="form.type"
+            class="col-md-6 col-12"
+          />
+          <DefaultPasswordInput
             v-model="form.password"
-            :label="$t('users.password')"
-            :rules="props.user ? [] : [inputRules.required, inputRules.min(6)]"
-            class="col-6"
+            :rules="[inputRules.required, inputRules.min(6)]"
+            class="col-md-6 col-12"
           />
         </q-card-section>
         <q-card-actions align="center">
@@ -31,7 +36,8 @@
             color="primary"
             label="OK"
             :type="'submit'"
-            :disable="!hasChanges"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
           />
         </q-card-actions>
       </q-form>
@@ -39,12 +45,16 @@
   </q-dialog>
 </template>
 <script setup>
-import { ref, computed, useTemplateRef } from "vue";
+import { ref, useTemplateRef, watch } from "vue";
 import { useInputRules } from "src/composables/useInputRules";
 import { useDialogPluginComponent } from "quasar";
 import { useI18n } from "vue-i18n";
+import { createUser, updateUser } from "src/api/user";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 
-import DefaultDialogHeader from "src/components/geral/DefaultDialogHeader.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import UserTypeSelect from "./UserTypeSelect.vue";
 
 defineEmits([
   // REQUIRED; need to specify some events that your
@@ -52,14 +62,14 @@ defineEmits([
   ...useDialogPluginComponent.emits,
 ]);
 
-const props = defineProps({
+const { user, title } = defineProps({
   user: {
     type: Object,
     default: null,
   },
   title: {
     type: Function,
-    default: () => useI18n().t("general.title"),
+    default: () => useI18n().t("common.terms.title"),
   },
 });
 
@@ -70,39 +80,50 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 
 const formRef = useTemplateRef("formRef");
 
-const form = ref({
-  name: props.user ? props.user.name : "",
-  email: props.user ? props.user.email : "",
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name: user ? user?.name : "",
+  email: user ? user?.email : "",
+  type: user ? user?.type : "",
   password: "",
 });
-const originalForm = { ...form.value };
 
-const hasChanges = computed(
-  () => Object.keys(getChangedFields.value).length > 0,
-);
-const getChangedFields = computed(() => {
-  const changedFields = {};
-  for (const key in form.value) {
-    if (form.value[key] !== originalForm[key]) {
-      changedFields[key] = form.value[key];
-    }
-  }
-  return changedFields;
-});
+const selectedUserType = ref(null);
+const loading = ref(false);
 
 // this is part of our example (so not required)
-const onOKClick = () => {
-  if (!formRef.value.validate()) {
+const onOKClick = async () => {
+  if (!(await formRef.value.validate())) {
     return;
   }
 
-  if (props.user) {
+  if (user) {
     // When editing, only send changed fields
-    const changedFields = getChangedFields.value;
-    onDialogOK(changedFields);
+    loading.value = true;
+    try {
+      await updateUser(user.id, getUpdatedFields.value);
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
   } else {
     // When creating, send all fields
-    onDialogOK({ ...form.value });
+    loading.value = true;
+    try {
+      await createUser({ ...form });
+    } catch (error) {
+      console.error(error);
+      return;
+    } finally {
+      loading.value = false;
+    }
+    onDialogOK(true);
   }
 };
+
+watch(selectedUserType, () => {
+  form.type = selectedUserType.value.value
+});
 </script>

+ 45 - 0
src/pages/users/components/UserTypeSelect.vue

@@ -0,0 +1,45 @@
+<template>
+  <q-select
+    v-bind="$attrs"
+    v-model="selectedUserType"
+    :label="label"
+    :options="userTypeOptions"
+    :rules="rules"
+  />
+</template>
+<script setup>
+import { onMounted, ref } from "vue";
+import { userTypes } from "src/api/user";
+import { useI18n } from "vue-i18n";
+
+const { label, rules, type } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("common.ui.misc.type"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  type: {
+    type: String,
+    default: null,
+  },
+});
+
+const selectedUserType = defineModel();
+const userTypeOptions = ref([]);
+
+onMounted(async () => {
+  const response = await userTypes();
+  const values = Object.entries(response);
+  values.forEach(([key, value]) => {
+    userTypeOptions.value.push({ label: value, value: key });
+  });
+  if (type) {
+    selectedUserType.value = userTypeOptions.value.find(
+      (option) => option.value === type,
+    );
+  }
+});
+</script>

+ 1 - 1
src/router/index.js

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

+ 2 - 2
src/router/routes.js

@@ -17,13 +17,13 @@ const routes = [
         name: "DashboardPage",
         component: () => import("src/pages/dashboard/DashboardPage.vue"),
         meta: {
-          title: "navigation.dashboard",
+          title: "ui.navigation.dashboard",
           requireAuth: true,
           requiredPermission: "dashboard",
           breadcrumbs: [
             {
               name: "DashboardPage",
-              title: "navigation.dashboard",
+              title: "ui.navigation.dashboard",
             },
           ],
         },

+ 62 - 0
src/router/routes/regions.route.js

@@ -0,0 +1,62 @@
+export default [
+  {
+    path: "/city",
+    name: "CityPage",
+    component: () => import("pages/city/CityPage.vue"),
+    meta: {
+      title: "ui.navigation.city",
+      requireAuth: true,
+      requiredPermission: "config.city",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "CityPage",
+          title: "ui.navigation.city",
+        },
+      ],
+    },
+  },
+  {
+    path: "/country",
+    name: "CountryPage",
+    component: () => import("pages/country/CountryPage.vue"),
+    meta: {
+      title: "ui.navigation.country",
+      requireAuth: true,
+      requiredPermission: "config.country",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "CountryPage",
+          title: "ui.navigation.country",
+        },
+      ],
+    },
+  },
+  {
+    path: "/state",
+    name: "StatePage",
+    component: () => import("pages/state/StatePage.vue"),
+    meta: {
+      title: "ui.navigation.state",
+      requireAuth: true,
+      requiredPermission: "config.state",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "StatePage",
+          title: "ui.navigation.state",
+        },
+      ],
+    },
+  },
+]

+ 4 - 6
src/router/routes/users.route.js

@@ -1,24 +1,22 @@
-const routes = [
+export default [
   {
     path: "/users",
     name: "UsersPage",
     component: () => import("pages/users/UsersPage.vue"),
     meta: {
-      title: "navigation.users",
+      title: "ui.navigation.users",
       requireAuth: true,
       requiredPermission: "config.user",
       breadcrumbs: [
         {
           name: "DashboardPage",
-          title: "navigation.dashboard",
+          title: "ui.navigation.dashboard",
         },
         {
           name: "UsersPage",
-          title: "navigation.users",
+          title: "ui.navigation.users",
         },
       ],
     },
   },
 ];
-
-export default routes;

+ 90 - 0
src/stores/navigation.js

@@ -0,0 +1,90 @@
+import { defineStore } from "pinia";
+import { computed } from "vue";
+import { permissionStore } from "src/stores/permission";
+
+export const navigationStore = defineStore("navigation", () => {
+  const navigationStructure = Object.freeze([
+    {
+      type: "single",
+      title: "ui.navigation.dashboard",
+      name: "DashboardPage",
+      icon: "mdi-home-variant-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "dashboard",
+    },
+    {
+      type: "expansive",
+      title: "ui.navigation.registration",
+      icon: "mdi-cog-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "config",
+      childrens: [
+        {
+          type: "single",
+          title: "ui.navigation.users",
+          name: "UsersPage",
+          icon: "mdi-account-multiple-outline",
+          disable: false,
+          permission: false,
+          permissionScope: "config.user",
+        },
+        {
+          type: "single",
+          title: "ui.navigation.city",
+          name: "CityPage",
+          icon: "mdi-city-variant-outline",
+          disable: false,
+          permission: false,
+          permissionScope: "config.city",
+        },
+        {
+          type: "single",
+          title: "ui.navigation.country",
+          name: "CountryPage",
+          icon: "mdi-earth",
+          disable: false,
+          permission: false,
+          permissionScope: "config.country",
+        },
+        {
+          type: "single",
+          title: "ui.navigation.state",
+          name: "StatePage",
+          icon: "mdi-map-marker",
+          disable: false,
+          permission: false,
+          permissionScope: "config.state",
+        },
+      ],
+    },
+  ]);
+
+  const getNavigationAccess = () => {
+    const { getAccess } = permissionStore();
+    return navigationStructure
+      .map((menu) => {
+        if (menu.type === "expansive") {
+          if (getAccess(menu.permissionScope, "menu")) {
+            menu.permission = true;
+          }
+          menu.childrens = menu.childrens.filter((children) => {
+            children.permission = getAccess(children.permissionScope, "menu");
+            return children.permission;
+          });
+          return menu.childrens.length > 0 ? menu : null;
+        } else {
+          menu.permission = getAccess(menu.permissionScope, "menu");
+          return menu;
+        }
+      })
+      .filter((menu) => menu !== null);
+  };
+
+  const navigationItems = computed(() => getNavigationAccess());
+
+  return {
+    navigationItems,
+  };
+});

+ 5 - 3
src/stores/permission.js

@@ -97,9 +97,11 @@ export const permissionStore = defineStore("permission", () => {
     try {
       const accessToken = Cookies.get("access_token");
       if (accessToken) {
-        await userStore().fetchUser();
-        const response = await getUserPermissions();
-        permissions.value = response;
+        const [userPermissions] = await Promise.allSettled([
+          getUserPermissions(),
+          userStore().fetchUser(),
+        ]);
+        permissions.value = userPermissions.value;
       } else {
         const response = await getGuestPermissions();
         permissions.value = response;

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.