Przeglądaj źródła

feat: New DefaultInput and DefaultSelect to make editing styles across
the system easier, new LoginPage, new SystemVersionPage and a bunch of
changes to revamp the Skeleton

Denis 2 miesięcy temu
rodzic
commit
ee19645745
53 zmienionych plików z 2369 dodań i 1210 usunięć
  1. 1 0
      eslint.config.js
  2. 6 6
      package-lock.json
  3. 7 4
      src/App.vue
  4. 1 1
      src/api/cacheService.js
  5. 1 3
      src/boot/axios.js
  6. 8 3
      src/boot/defaultPropsComponents.js
  7. 1 0
      src/boot/i18n.js
  8. 7 3
      src/components/charts/CardIconChart.vue
  9. 11 4
      src/components/charts/CardIconMiniChart.vue
  10. 17 42
      src/components/defaults/DefaultCepInput.vue
  11. 45 38
      src/components/defaults/DefaultCurrencyInput.vue
  12. 1 1
      src/components/defaults/DefaultDialogHeader.vue
  13. 127 66
      src/components/defaults/DefaultFilePicker.vue
  14. 110 0
      src/components/defaults/DefaultInput.vue
  15. 60 76
      src/components/defaults/DefaultInputDatePicker.vue
  16. 4 21
      src/components/defaults/DefaultPasswordInput.vue
  17. 91 0
      src/components/defaults/DefaultSelect.vue
  18. 60 45
      src/components/defaults/DefaultTable.vue
  19. 13 7
      src/components/defaults/DefaultTableServerSide.vue
  20. 123 8
      src/components/layout/DefaultHeaderPage.vue
  21. 261 231
      src/components/layout/LeftMenuLayout.vue
  22. 103 79
      src/components/layout/LeftMenuLayoutMobile.vue
  23. 13 21
      src/components/selects/CitySelect.vue
  24. 17 27
      src/components/selects/CountrySelect.vue
  25. 21 37
      src/components/selects/StateSelect.vue
  26. 13 33
      src/components/selects/UserTypeSelect.vue
  27. 2 1
      src/composables/useInputRules.js
  28. 86 2
      src/css/app.scss
  29. 7 9
      src/css/table.scss
  30. 35 1
      src/i18n/locales/en.json
  31. 35 1
      src/i18n/locales/es.json
  32. 40 6
      src/i18n/locales/pt.json
  33. 36 55
      src/layouts/MainLayout.vue
  34. 0 136
      src/pages/LoginPage.vue
  35. 41 12
      src/pages/city/CityPage.vue
  36. 28 28
      src/pages/city/components/AddEditCityDialog.vue
  37. 38 11
      src/pages/country/CountryPage.vue
  38. 24 18
      src/pages/country/components/AddEditCountryDialog.vue
  39. 13 0
      src/pages/home/HomePage.vue
  40. 134 0
      src/pages/login/LoginPage.vue
  41. 231 0
      src/pages/login/component/WavePattern.vue
  42. 38 11
      src/pages/state/StatePage.vue
  43. 29 25
      src/pages/state/components/AddEditStateDialog.vue
  44. 38 18
      src/pages/users/UsersPage.vue
  45. 27 24
      src/pages/users/components/AddEditUserDialog.vue
  46. 149 0
      src/pages/version/SystemVersionsPage.vue
  47. 63 0
      src/pages/version/data/versions.js
  48. 3 3
      src/router/index.js
  49. 39 4
      src/router/routes.js
  50. 98 0
      src/router/routes/config.route.js
  51. 0 62
      src/router/routes/regions.route.js
  52. 0 22
      src/router/routes/users.route.js
  53. 13 5
      src/stores/navigation.js

+ 1 - 0
eslint.config.js

@@ -68,6 +68,7 @@ export default [
       "vue/no-unused-vars": "warn",
       "vue/no-unused-components": "warn",
       "@intlify/vue-i18n/no-dynamic-keys": "off",
+      "@intlify/vue-i18n/no-raw-text": "off",
       "@intlify/vue-i18n/no-unused-keys": [
         "error",
         {

+ 6 - 6
package-lock.json

@@ -6856,9 +6856,9 @@
       }
     },
     "node_modules/lodash": {
-      "version": "4.17.21",
-      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
-      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "version": "4.17.23",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
+      "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
       "dev": true,
       "license": "MIT"
     },
@@ -7802,9 +7802,9 @@
       }
     },
     "node_modules/qs": {
-      "version": "6.14.0",
-      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
-      "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+      "version": "6.14.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
+      "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
       "license": "BSD-3-Clause",
       "dependencies": {
         "side-channel": "^1.1.0"

+ 7 - 4
src/App.vue

@@ -18,16 +18,17 @@ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
   ? "dark"
   : "light";
 
-const theme = $q.cookies.get("theme") || systemTheme;
-const localeCookie = Cookies.get("locale") || window.navigator.language;
-console.log(theme, localeCookie);
+const theme = Cookies.get("theme") || systemTheme;
+
 $q.dark.set(theme == "dark");
 
 watch(
   () => $q.dark.isActive,
   (value) => {
-    $q.cookies.set("theme", value ? "dark" : "light", {
+    Cookies.set("theme", value ? "dark" : "light", {
       expires: 365,
+      sameSite: "Lax",
+      path: "/",
     });
   },
 );
@@ -37,6 +38,8 @@ watch(
   (value) => {
     Cookies.set("locale", value, {
       expires: 365,
+      sameSite: "Lax",
+      path: "/",
     });
   },
 );

+ 1 - 1
src/api/cacheService.js

@@ -83,7 +83,7 @@ const clearCache = async (cacheKey) => {
             new Promise((resolve) => {
               const deleteRequest = store.delete(key);
               deleteRequest.onsuccess = () => resolve();
-            })
+            }),
         );
 
       Promise.all(deletePromises).then(() => resolve());

+ 1 - 3
src/boot/axios.js

@@ -106,11 +106,9 @@ const successInterceptor = (response) => {
 };
 
 export default defineBoot(({ app }) => {
-  const router = useRouter();
-
   api.interceptors.response.use(
     (response) => successInterceptor(response),
-    (error) => errorInterceptor(error, router),
+    (error) => errorInterceptor(error, useRouter()),
   );
 
   // for use inside Vue files (Options API) through this.$axios and this.$api

+ 8 - 3
src/boot/defaultPropsComponents.js

@@ -20,11 +20,16 @@ export default defineBoot(() => {
     transitionHide: "slide-down",
   });
   SetComponentDefaults(QInput, {
-    filled: true,
+    rounded: true,
+    dark: true,
+    standout: true,
+    dense: true,
   });
   SetComponentDefaults(QSelect, {
-    filled: true,
-    behavior: "menu",
+    rounded: true,
+    standout: true,
+    dark: true,
+    dense: true,
   });
   SetComponentDefaults(QCard, {
     flat: true,

+ 1 - 0
src/boot/i18n.js

@@ -8,6 +8,7 @@ const i18n = createI18n({
     ? Cookies.get("locale")
     : window.navigator.language,
   globalInjection: true,
+  legacy: false,
   messages,
 });
 

+ 7 - 3
src/components/charts/CardIconChart.vue

@@ -1,7 +1,7 @@
 <template>
   <q-card
     flat
-    class="full-height q-pa-lg"
+    class="full-height q-pa-lg chart-card card-ring"
     :style="{
       minHeight: $q.screen.lt.sm ? '400px' : '600px',
       minWidth: $q.screen.lt.sm ? '200px' : '300px',
@@ -11,8 +11,8 @@
       <div class="flex justify-between items-center no-wrap">
         <span class="text-h5">{{ title }}</span>
         <div class="flex no-wrap flex-center">
-          <div class="round background">
-            <q-icon class="q-pa-sm" :name="icon" size="24px" :color="color" />
+          <div class="round">
+            <q-icon :name="icon" size="24px" :color="color" />
           </div>
           <!-- <q-icon
             v-if="downloadImage !== null"
@@ -74,4 +74,8 @@ body.body--dark {
     background: rgba(map.get($colors-dark, "primary"), 0.2) !important;
   }
 }
+
+.chart-card {
+  border-radius: 8px;
+}
 </style>

+ 11 - 4
src/components/charts/CardIconMiniChart.vue

@@ -1,10 +1,10 @@
 <template>
-  <q-card flat class="q-pa-lg" style="max-height: 184px; min-width: 305px;">
+  <q-card class="q-pa-lg mini-card card-ring">
     <div class="column no-wrap full-width">
       <div class="flex justify-between items-center no-wrap">
         <span class="text-h5">{{ title }}</span>
-        <div class="round background">
-          <q-icon class="q-pa-sm" :name="icon" size="24px" :color="color" />
+        <div class="round">
+          <q-icon :name="icon" size="24px" :color="color" />
         </div>
       </div>
       <div class="flex no-wrap full-width justify-between q-pa-sm">
@@ -59,7 +59,7 @@ const { color, title, icon, chartData, numberCard, numberPorcent } =
     },
     numberPorcent: {
       type: Number,
-      default: () => Math.ceil(Math.random() * 200 - 100),
+      default: null,
     },
   });
 </script>
@@ -78,4 +78,11 @@ body.body--dark {
     background: rgba(map.get($colors-dark, "primary"), 0.2) !important;
   }
 }
+
+.mini-card {
+  height: 100%;
+  min-width: 305px;
+  border-radius: 8px;
+  box-shadow: 0 0 0 1px #c0c0c0c0 !important;
+}
 </style>

+ 17 - 42
src/components/defaults/DefaultCepInput.vue

@@ -1,57 +1,32 @@
 <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"
-      :error
-      :error-message
-    />
-  </div>
+  <DefaultInput
+    v-model="model"
+    v-bind="$attrs"
+    debounce="500"
+    :class="disable ?? 'no-pointer-events'"
+    :label
+    :mask="masks.Brasil.cep"
+    :loading
+  />
 </template>
 <script setup>
-import { watch, computed, ref, nextTick } from "vue";
+import { watch, ref, nextTick } from "vue";
 import { useQuasar } from "quasar";
 import masks from "src/helpers/masks.js";
 
+import DefaultInput from "./DefaultInput.vue";
+
 const $q = useQuasar();
 
 const model = defineModel({
-  type: Number,
+  type: [String, Number],
 });
 
-const { disable, readonly, label, rules } = defineProps({
-  disable: {
-    type: Boolean,
-    default: false,
-  },
-  readonly: {
-    type: Boolean,
-    default: false,
-  },
+const { label } = defineProps({
   label: {
     type: String,
     default: "CEP",
   },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
-  error: {
-    type: Boolean,
-    default: false,
-  },
-  errorMessage: {
-    type: String,
-    default: "",
-  },
 });
 
 const emit = defineEmits([
@@ -62,11 +37,10 @@ const emit = defineEmits([
   "numero",
   "complemento",
   "uf",
+  "cepData",
 ]);
 
-const loading = ref(false);
-
-const newLabel = computed(() => label ?? void 0);
+const loading = ref(true);
 
 watch(
   () => model.value,
@@ -79,6 +53,7 @@ watch(
         loading.value = true;
         const response = await fetch(`https://viacep.com.br/ws/${value}/json`);
         const data = await response.json();
+        emit("cepData", data);
         emit("estado", data.estado);
         emit("uf", data.uf);
         // this is a hack to work well with the city and state select

+ 45 - 38
src/components/defaults/DefaultCurrencyInput.vue

@@ -1,27 +1,30 @@
 <template>
-  <q-input
+  <DefaultInput
     ref="inputRef"
     v-model="formattedValue"
-    outlined
-    :error-message
-    :error="!!errorMessageComp"
-    :class="disable ? 'no-pointer-events' : ''"
-    :label="newLabel"
-    :disable
-    :readonly
-  >
-  </q-input>
+    v-bind="$attrs"
+    :label="label"
+    :rules="finalRules"
+    :input-class="inputClass"
+  />
 </template>
+
 <script setup>
+import { watch, onBeforeMount, ref } from "vue";
 import { useCurrencyInput } from "vue-currency-input";
-import { computed, watch } from "vue";
 import { useI18n } from "vue-i18n";
+import { useInputRules } from "src/composables/useInputRules";
 
-const model = defineModel({
-  type: Number,
-});
+import DefaultInput from "./DefaultInput.vue";
+
+const { inputRules } = useInputRules();
 
-const { options, disable, readonly, label, error, errorMessage } = defineProps({
+const model = defineModel({ type: Number });
+
+const defaultRules = [inputRules.minValue(0)];
+const finalRules = ref([]);
+
+const { options, label, rules } = defineProps({
   options: {
     type: Object,
     default: () => ({
@@ -36,45 +39,49 @@ const { options, disable, readonly, label, error, errorMessage } = defineProps({
       accountingSign: false,
     }),
   },
-  disable: {
-    type: Boolean,
-    default: false,
-  },
-  readonly: {
-    type: Boolean,
-    default: false,
-  },
   label: {
     type: String,
-    default: () => useI18n().t("common.terms.currency"),
-  },
-  error: {
-    type: Boolean,
-    default: false,
+    default: useI18n().t("common.terms.currency"),
   },
   errorMessage: {
     type: String,
     default: "",
   },
+  inputClass: {
+    type: String,
+    default: "",
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
 });
 
 const { inputRef, formattedValue, numberValue, setValue } =
   useCurrencyInput(options);
 
-const errorMessageComp = computed(() => {
-  numberValue.value < 0
-    ? useI18n().t("validation.rules.value_smaller_than_zero")
-    : undefined;
-
-  return errorMessage ? errorMessage : (error ?? void 0);
-});
+watch(
+  () => model.value,
+  (newValue) => {
+    setValue(newValue);
+  },
+);
 
-const newLabel = computed(() => (label ? label : void 0));
+watch(
+  () => numberValue.value,
+  (newValue) => {
+    model.value = newValue;
+  },
+);
 
 watch(
-  () => model.value,
+  () => rules,
   (value) => {
-    setValue(value);
+    finalRules.value = [...value, ...defaultRules];
   },
 );
+
+onBeforeMount(() => {
+  finalRules.value = [...rules, ...defaultRules];
+});
 </script>

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

@@ -1,5 +1,5 @@
 <template>
-  <q-bar class="q-py-md" v-bind="$attrs" style="height: 50px">
+  <q-bar class="bg-transparent q-px-md" v-bind="$attrs" style="height: 55px">
     <q-icon v-if="icon" :name="icon" />
     <div>{{ title() }}</div>
 

+ 127 - 66
src/components/defaults/DefaultFilePicker.vue

@@ -1,25 +1,34 @@
 <template>
-  <q-field
-    v-model="model"
-    v-bind="$attrs"
-    borderless
-    :rules="rules"
-    :error="error"
-    :error-message="errorMessage"
-    class="custom-file-input"
+  <div
+    :class="attrs.class"
+    :style="attrs.style"
+    @click="pickFile"
+    @dragover="handleDragOver"
+    @dragleave="handleDragLeave"
+    @drop="handleDrop"
   >
-    <div class="column flex-center q-mb-sm full-width">
-      <span v-if="label" class="text-grey-6 q-mb-xs">{{ label }}</span>
+    <div v-if="label || $slots.label" class="q-pl-xs">
+      <slot name="label">
+        <span>{{ label }}</span>
+      </slot>
+      <span v-if="required" class="text-negative q-ml-xs">*</span>
+    </div>
+    <q-field
+      v-model="model"
+      v-bind="inputAttrs"
+      borderless
+      hide-bottom-space
+      :rules="rules"
+      :error="error"
+      :error-message="errorMessage"
+      class="image-preview-container"
+    >
       <div
-        class="image-preview-container"
+        class=""
         :class="{
           'has-image': preview,
           'is-dragging': isDragging,
         }"
-        @click="pickFile"
-        @dragover="handleDragOver"
-        @dragleave="handleDragLeave"
-        @drop="handleDrop"
       >
         <template v-if="!preview">
           <q-icon
@@ -81,12 +90,24 @@
         v-model="model"
         :accept="accept"
       />
-    </div>
-  </q-field>
+    </q-field>
+  </div>
 </template>
 
 <script setup>
-import { ref, watch, onUnmounted, useTemplateRef } from "vue";
+import {
+  ref,
+  watch,
+  onUnmounted,
+  useTemplateRef,
+  useAttrs,
+  computed,
+  onBeforeMount,
+} from "vue";
+
+defineOptions({
+  inheritAttrs: false,
+});
 
 const { label, rules, accept, type, initialImage } = defineProps({
   label: {
@@ -119,12 +140,16 @@ const { label, rules, accept, type, initialImage } = defineProps({
   },
 });
 
+const attrs = useAttrs();
+const fileInputRef = useTemplateRef("fileInputRef");
+
 const model = defineModel({ type: [File, String, null], default: null });
 const base64File = defineModel("base64File", { type: String, default: null });
 
 const isDragging = ref(false);
-const fileInputRef = useTemplateRef("fileInputRef");
 const preview = ref(initialImage || null);
+const required = ref(false);
+
 let objectUrl = null;
 
 const cleanupObjectURL = () => {
@@ -133,7 +158,6 @@ const cleanupObjectURL = () => {
     objectUrl = null;
   }
 };
-onUnmounted(cleanupObjectURL);
 
 const generateBase64 = (file) => {
   if (!file) {
@@ -151,23 +175,6 @@ const generateBase64 = (file) => {
   reader.readAsDataURL(file);
 };
 
-watch(model, (newFile) => {
-  cleanupObjectURL();
-
-  if (newFile && newFile instanceof File) {
-    if (type === "image") {
-      objectUrl = URL.createObjectURL(newFile);
-      preview.value = objectUrl;
-    } else {
-      preview.value = "file_selected";
-    }
-    generateBase64(newFile);
-  } else {
-    preview.value = initialImage || null;
-    base64File.value = null;
-  }
-});
-
 const pickFile = () => {
   fileInputRef.value?.pickFiles();
 };
@@ -211,47 +218,101 @@ const handleDrop = (event) => {
     }
   }
 };
+
+const inputAttrs = computed(() => {
+  // eslint-disable-next-line
+  const { class: _, style: __, ...rest } = attrs;
+  return rest;
+});
+
+watch(model, (newFile) => {
+  cleanupObjectURL();
+
+  if (newFile && newFile instanceof File) {
+    if (type === "image") {
+      objectUrl = URL.createObjectURL(newFile);
+      preview.value = objectUrl;
+    } else {
+      preview.value = "file_selected";
+    }
+    generateBase64(newFile);
+  } else {
+    preview.value = initialImage || null;
+    base64File.value = null;
+  }
+});
+
+watch(
+  () => rules,
+  (values) => {
+    values.forEach((r) => {
+      if (r?.$id === "required") return (required.value = true);
+    });
+  },
+);
+
+onBeforeMount(() => {
+  rules.forEach((r) => {
+    if (r?.$id === "required") return (required.value = true);
+  });
+});
+
+onUnmounted(cleanupObjectURL);
 </script>
 
-<style lang="scss" scoped>
+<style lang="scss">
 @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")};
-  }
+  display: flex;
+  justify-content: center;
+  align-items: center;
 
-  .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")};
-  }
+  .q-field__inner {
+    .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")};
+    }
 
-  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;
+    .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")};
+    }
+
+    display: flex;
+    flex-direction: column;
+    transition: all 0.3s;
+
+    &.is-dragging {
+      border-color: var(--image-border-hover-color);
+      background-color: var(--image-bg-color);
+      opacity: 0.8;
+    }
+
+    &.has-image {
+      border-style: solid;
+      margin: auto;
+    }
+  }
+  .q-field__control {
+    height: 100%;
+    min-height: 200px;
+    border: 2px dashed var(--image-border-color);
+    border-radius: 8px;
+    cursor: pointer;
   }
 
-  &:hover {
-    border-color: var(--image-border-hover-color);
-    background-color: var(--image-bg-color);
+  .q-field__control-container {
+    justify-content: center;
   }
 
-  &.has-image {
-    border-style: solid;
+  .q-field__append {
+    position: absolute;
+    top: -5px;
+    right: 10px;
   }
 }
 </style>

+ 110 - 0
src/components/defaults/DefaultInput.vue

@@ -0,0 +1,110 @@
+<template>
+  <div class="column" :class="attrs.class" :style="attrs.style">
+    <div v-if="label || $slots.label" class="q-pl-xs">
+      <slot name="label">
+        <span>{{ label }}</span>
+      </slot>
+      <span v-if="required" class="text-negative q-ml-xs">*</span>
+    </div>
+    <div class="col">
+      <q-input
+        ref="inputRef"
+        v-model="model"
+        v-bind="inputAttrs"
+        :error="!!error"
+        :error-message="errorMessage"
+        :rules
+        hide-bottom-space
+        :class="inputClass"
+        :input-class="nativeInputClass"
+        @update:model-value="error = null"
+      >
+        <template v-for="(_, slotName) in $slots" #[slotName]>
+          <slot :name="slotName" />
+        </template>
+      </q-input>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {
+  ref,
+  onBeforeMount,
+  useAttrs,
+  computed,
+  watch,
+  useTemplateRef,
+} from "vue";
+
+defineOptions({
+  inheritAttrs: false,
+});
+
+const { label, nativeInputClass, inputClass, rules } = defineProps({
+  label: {
+    type: String,
+    default: "",
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  nativeInputClass: {
+    type: String,
+    default: null,
+  },
+  inputClass: {
+    type: String,
+    default: null,
+  },
+});
+
+const attrs = useAttrs();
+
+const inputRef = useTemplateRef("inputRef");
+
+const model = defineModel({ type: [String, Object, Array, Boolean, null] });
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+});
+
+const required = ref(false);
+
+const inputAttrs = computed(() => {
+  // eslint-disable-next-line
+  const { class: _, style: __, ...rest } = attrs;
+  return rest;
+});
+
+const errorMessage = computed(() => {
+  if (error.value == null) {
+    return void 0;
+  }
+  if (typeof error.value === "boolean") {
+    return void 0;
+  }
+  return String(error.value);
+});
+
+watch(
+  () => rules,
+  (values) => {
+    values.forEach((r) => {
+      if (r?.$id === "required") return (required.value = true);
+    });
+  },
+);
+
+onBeforeMount(() => {
+  rules.forEach((r) => {
+    if (r?.$id === "required") return (required.value = true);
+  });
+});
+
+defineExpose({
+  inputRef,
+});
+</script>
+
+<style scoped></style>

+ 60 - 76
src/components/defaults/DefaultInputDatePicker.vue

@@ -1,95 +1,79 @@
 <template>
-  <div v-bind="$attrs">
-    <q-input
-      ref="inputRef"
-      v-model="treatedDate"
-      :mask="inputMask"
-      :label
-      :rules
-      :dense
-      :error
-      :error-message
-      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>
+  <DefaultInput
+    v-model="treatedDate"
+    v-model:error="error"
+    v-bind="$attrs"
+    :input-ref="inputRef"
+    :label="label"
+    :mask="inputMask"
+    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="OK" 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>
+  </DefaultInput>
 </template>
 
 <script setup>
-import { watch, ref, computed, useTemplateRef } from "vue";
+import { watch, ref, computed } from "vue";
 import { useI18n } from "vue-i18n";
 import masks from "src/helpers/masks";
 
-const { label, rules, time, dense } = defineProps({
+import DefaultInput from "./DefaultInput.vue";
+
+const { label, time } = defineProps({
   label: {
     type: String,
     default: () => useI18n().t("common.terms.date"),
   },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
-  dense: {
-    type: Boolean,
-    default: false,
-  },
   time: {
     type: Boolean,
     default: false,
   },
-  error: {
-    type: Boolean,
-    default: false,
-  },
-  errorMessage: {
-    type: String,
-    default: "",
-  },
 });
 
-const qInputRef = useTemplateRef("inputRef", { type: String });
-
-const treatedDate = defineModel({ type: String });
-const untreatedDate = defineModel("untreatedDate", { type: String });
+const treatedDate = defineModel({ type: [String, null] });
+const untreatedDate = defineModel("untreatedDate", { type: [String, null] });
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+});
 
+const inputRef = ref(null);
 const activePanel = ref("date");
 const date = ref();
 
@@ -118,7 +102,7 @@ const unformatDate = (value) => {
 };
 
 const inputMask = computed(() => {
-  if (!qInputRef.value) return "";
+  if (!inputRef.value) return "";
 
   if (time) {
     return masks.Brasil.datetime;
@@ -157,6 +141,6 @@ watch(
     date.value = value;
     treatedDate.value = formatDate(value);
   },
-  { immediate: true },
+  { immediate: true }
 );
 </script>

+ 4 - 21
src/components/defaults/DefaultPasswordInput.vue

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

+ 91 - 0
src/components/defaults/DefaultSelect.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="column" :class="attrs.class" :style="attrs.style">
+    <div v-if="label || $slots.label" class="q-pl-xs">
+      <slot name="label">
+        <span>{{ label }}</span>
+      </slot>
+      <span v-if="required" class="text-negative q-ml-xs">*</span>
+    </div>
+    <div class="col">
+      <q-select
+        ref="selectRef"
+        v-model="model"
+        v-bind="selectAttrs"
+        :error="!!error"
+        :error-message="errorMessage"
+        :rules
+        hide-bottom-space
+        :class="inputClass"
+        :popup-content-class="popupContentClass"
+        @update:model-value="error = null"
+      >
+        <template v-for="(_, slotName) in $slots" #[slotName]="scope">
+          <slot :name="slotName" v-bind="scope" />
+        </template>
+      </q-select>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onBeforeMount, useAttrs, computed } from "vue";
+
+defineOptions({
+  inheritAttrs: false,
+});
+
+const { label, inputClass, popupContentClass, rules } = defineProps({
+  label: {
+    type: String,
+    default: "",
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  inputClass: {
+    type: String,
+    default: null,
+  },
+  popupContentClass: {
+    type: String,
+    default: null,
+  },
+});
+
+const attrs = useAttrs();
+
+const model = defineModel({ type: [String, Object, Array, Number, null] });
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+});
+
+const selectRef = ref(null);
+const required = ref(false);
+
+const selectAttrs = computed(() => {
+  // eslint-disable-next-line
+  const { class: _, style: __, ...rest } = attrs;
+  return rest;
+});
+
+const errorMessage = computed(() => {
+  if (error.value == null) {
+    return void 0;
+  }
+  if (typeof error.value === "boolean") {
+    return void 0;
+  }
+  return String(error.value);
+});
+
+onBeforeMount(() => {
+  rules.forEach((r) => {
+    if (r?.$id === "required") return (required.value = true);
+  });
+});
+
+defineExpose({
+  selectRef,
+});
+</script>

+ 60 - 45
src/components/defaults/DefaultTable.vue

@@ -10,32 +10,37 @@
     :visible-columns
     :filter
     :columns
-    :rows
     :loading
-    class="softpar-table q-pa-sm"
+    :rows
+    class="softpar-table q-pa-sm q-mt-md"
     @row-click="onRowClick"
   >
     <template #top>
       <div
-        class="flex full-width justify-between align-center q-mb-md q-pl-sm"
+        class="flex full-width align-center q-mb-md q-pl-sm"
         style="gap: 1rem"
       >
-        <q-input
+        <div v-if="title" class="column text-h6">
+          <span>{{ title }}</span>
+          <span class="text-body2">{{
+            rows.length + " " + $t("common.ui.table.records_found")
+          }}</span>
+        </div>
+        <DefaultInput
           v-if="showSearchField"
           v-model="filter"
           debounce="250"
           :placeholder="$t('common.actions.search')"
           clearable
           autofocus
-          class=""
+          class="q-mt-sm q-ml-sm"
           color="primary"
         >
           <template #append>
             <q-icon name="mdi-magnify" />
           </template>
-        </q-input>
-
-        <q-select
+        </DefaultInput>
+        <DefaultSelect
           v-if="showColumnsSelect"
           v-model="visibleColumns"
           multiple
@@ -47,11 +52,7 @@
           style="width: 150px"
           options-selected-class="text-bold"
         />
-
         <slot name="top" :rows="rows" />
-
-        <q-space />
-
         <q-btn
           v-if="addItem"
           color="primary"
@@ -66,14 +67,13 @@
 
     <template #body-cell-actions="{ row }">
       <q-td auto-width>
-        <slot name="body-cell-actions" :row="row" />
-        <q-item-section v-if="deleteFunction">
+        <q-item-section class="no-wrap" style="flex-direction: row">
+          <slot name="body-cell-actions" :row="row" />
           <q-btn
-            color="negative"
-            flat
-            dense
-            icon="mdi-delete"
-            style="width: 45px"
+            v-if="deleteFunction"
+            outline
+            icon="mdi-trash-can-outline"
+            style="width: 36px"
             class="q-ml-auto q-mr-sm"
             @click.prevent.stop="onDelete(row.id)"
           />
@@ -104,13 +104,12 @@
                 <slot name="body-cell-actions" :row="row" />
                 <q-item-section v-if="deleteFunction">
                   <q-btn
-                    color="negative"
-                    flat
-                    dense
-                    icon="mdi-delete"
-                    style="width: 45px"
-                    class="q-ml-auto q-mr-sm"
-                    @click.prevent.stop="onDelete(col.id)"
+                    v-if="deleteFunction"
+                    outline
+                    icon="mdi-trash-can-outline"
+                    style="width: 36px"
+                    class="q-mr-sm"
+                    @click.prevent.stop="onDelete(row.id)"
                   />
                 </q-item-section>
               </template>
@@ -131,9 +130,8 @@
         </div>
       </div>
     </template>
-
-    <template v-for="name in $slots" #[name]="data">
-      <slot :name="name" v-bind="data"></slot>
+    <template v-for="slotName in usableSlots($slots)" #[slotName]="data">
+      <slot :name="slotName" v-bind="data" />
     </template>
   </q-table>
 </template>
@@ -144,6 +142,9 @@ import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 import { useQuasar } from "quasar";
 
+import DefaultInput from "./DefaultInput.vue";
+import DefaultSelect from "./DefaultSelect.vue";
+
 const emit = defineEmits(["onRowClick", "onAddItem", "noRows"]);
 
 const {
@@ -160,13 +161,17 @@ const {
   hideNoDataLabel,
   deleteFunction,
 } = defineProps({
+  title: {
+    type: String,
+    default: null,
+  },
   columns: {
     type: Array,
     required: true,
   },
   apiCall: {
     type: Function,
-    required: true,
+    default: null,
   },
   outlineAdd: {
     type: Boolean,
@@ -182,7 +187,7 @@ const {
   },
   addItem: {
     type: Boolean,
-    default: true,
+    default: false,
   },
   addItemRoute: {
     type: String,
@@ -198,7 +203,7 @@ const {
   },
   showColumnsSelect: {
     type: Boolean,
-    default: true,
+    default: false,
   },
   noApiCall: {
     type: Boolean,
@@ -218,22 +223,11 @@ const router = useRouter();
 const { t } = useI18n();
 const $q = useQuasar();
 
-const rows = ref([]);
+const rows = defineModel("rows", { type: Array, default: [] });
 const filter = ref("");
 const loading = ref(true);
 const fullscreen = ref(false);
 
-const getPaginationLabel = (from, to, last) => {
-  return `${from}-${to} de ${last}`;
-};
-
-watch(
-  () => apiCall,
-  async () => {
-    await onRequest();
-  },
-);
-
 const mapColumns = columns.reduce((accm, column) => {
   if (!column.required) {
     accm.push({
@@ -246,6 +240,10 @@ const mapColumns = columns.reduce((accm, column) => {
 
 const visibleColumns = ref(mapColumns.map((column) => column.value));
 
+const getPaginationLabel = (from, to, last) => {
+  return `${from}-${to} ${t("common.ui.table.of")} ${last}`;
+};
+
 const onRowClick = (evt, row, index) => {
   const item = toRaw(row);
   if (openItem) {
@@ -278,6 +276,7 @@ const onDelete = async (id) => {
       },
       cancel: {
         color: "primary",
+        outline: true,
         label: t("common.actions.cancel"),
       },
     }).onOk(async () => {
@@ -302,7 +301,7 @@ const onRequest = async () => {
   loading.value = true;
 
   const response = await apiCall();
-  rows.value.splice(0, rows.value.length, ...response);
+  rows.value = response;
 
   if (rows.value.length == 0) {
     emit("noRows");
@@ -310,6 +309,22 @@ const onRequest = async () => {
   loading.value = false;
 };
 
+const usableSlots = (slots) => {
+  const availableSlots = Object.keys(slots);
+  const usableSlots = availableSlots.filter(
+    (slot) =>
+      !["body-cell-actions", "top", "loading", "no-data"].includes(slot),
+  );
+  return usableSlots;
+};
+
+watch(
+  () => apiCall,
+  async () => {
+    await onRequest();
+  },
+);
+
 onMounted(async () => {
   await onRequest({
     filter: undefined,

+ 13 - 7
src/components/defaults/DefaultTableServerSide.vue

@@ -22,7 +22,7 @@
         class="flex full-width justify-between items-center q-mb-md q-pl-sm"
         style="gap: 1rem"
       >
-        <q-input
+        <DefaultInput
           v-if="showSearchField"
           v-model="pagination.filter"
           outlined
@@ -35,9 +35,9 @@
           <template #append>
             <q-icon name="mdi-magnify" />
           </template>
-        </q-input>
+        </DefaultInput>
 
-        <q-select
+        <DefaultSelect
           v-if="showColumnsSelect"
           v-model="visibleColumns"
           class="q-ml-md"
@@ -95,7 +95,7 @@
       <div class="flex full-width justify-end">
         <div class="flex items-center">
           {{ $t("common.ui.table.rows_per_page") }}
-          <q-select
+          <DefaultSelect
             v-model="pagination.rowsPerPage"
             class="q-mx-sm"
             dense
@@ -106,15 +106,18 @@
               <q-item v-bind="selectData.itemProps">
                 <q-item-section>
                   <q-item-label>{{
-                    selectData.opt == 0 ? $t("common.ui.misc.all") : selectData.opt
+                    selectData.opt == 0
+                      ? $t("common.ui.misc.all")
+                      : selectData.opt
                   }}</q-item-label>
                 </q-item-section>
               </q-item>
             </template>
-          </q-select>
+          </DefaultSelect>
         </div>
         <div class="flex items-center">
-          {{ pagination.from + "-" + pagination.to }} {{ $t("common.ui.table.of") }}
+          {{ pagination.from + "-" + pagination.to }}
+          {{ $t("common.ui.table.of") }}
           {{ pagination.rowsNumber }}
         </div>
         <div class="flex items-center">
@@ -151,6 +154,9 @@ import { ref, computed, onMounted, toRaw, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 
+import DefaultInput from "./DefaultInput.vue";
+import DefaultSelect from "./DefaultSelect.vue";
+
 const emit = defineEmits([
   "onRowClick",
   "onAddItem",

+ 123 - 8
src/components/layout/DefaultHeaderPage.vue

@@ -1,19 +1,134 @@
 <template>
   <div>
-    <q-breadcrumbs class="q-mb-md">
+    <q-breadcrumbs
+      v-if="displayBreadcrumbs != null"
+      class="q-mb-xs"
+      :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
+    >
       <q-breadcrumbs-el
-        v-for="breadcrumb in $route.meta?.breadcrumbs"
-        :key="breadcrumb?.name"
-        :label="$t(breadcrumb?.title)"
-        :to="{ name: breadcrumb?.name }"
+        v-for="crumb in displayBreadcrumbs"
+        :key="crumb.name || crumb.label"
+        :label="crumb.title"
+        :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
       />
     </q-breadcrumbs>
+    <div
+      v-else
+      style="max-width: 180px"
+      :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
+    >
+      <q-skeleton type="text" />
+    </div>
+
     <div class="flex items-center justify-between">
-      <span class="text-h5">{{ $t($route.meta?.title) }}</span>
-      <slot name="after" />
+      <div class="column q-pl-xs" :class="$q.screen.lt.sm ? '' : 'q-pt-md'">
+        <span v-if="displayTitle" class="text-h4 text-primary q-mb-xs">
+          {{ displayTitle }}
+        </span>
+        <div v-else style="width: 280px">
+          <q-skeleton type="text" height="40px" />
+        </div>
+        <span v-if="displayDescription" class="text-body2">
+          {{ displayDescription }}
+        </span>
+        <div v-else style="width: 380px">
+          <q-skeleton type="text" height="20px" />
+        </div>
+      </div>
+      <div>
+        <slot name="after" />
+      </div>
     </div>
     <q-separator class="q-my-sm" />
   </div>
 </template>
 
-<script setup></script>
+<script setup>
+import { computed } from "vue";
+import { useRoute } from "vue-router";
+import { useI18n } from "vue-i18n";
+
+const { title, description, breadcrumbs } = defineProps({
+  title: {
+    type: Object,
+    default: null,
+  },
+  description: {
+    type: Object,
+    default: null,
+  },
+  breadcrumbs: {
+    type: Object,
+    default: null,
+  },
+});
+
+const route = useRoute();
+const { t } = useI18n();
+
+const displayTitle = computed(() => {
+  if (title) {
+    if (title.translate) {
+      return t(title.value);
+    } else {
+      return title.value;
+    }
+  } else if (route.meta?.title) {
+    if (route.meta?.title.translate) {
+      return t(route.meta?.title.value);
+    } else {
+      return route.meta?.title.value;
+    }
+  }
+  return null;
+});
+
+const displayDescription = computed(() => {
+  if (description) {
+    if (description.translate) {
+      return t(description.value);
+    } else {
+      return description.value;
+    }
+  } else if (route.meta?.description) {
+    if (route.meta?.description.translate) {
+      return t(route.meta?.description.value);
+    } else {
+      return route.meta?.description.value;
+    }
+  }
+  return null;
+});
+
+const displayBreadcrumbs = computed(() => {
+  if (!breadcrumbs && breadcrumbs?.length <= 0) {
+    return null;
+  } else if (breadcrumbs && breadcrumbs?.length > 0) {
+    return (breadcrumbs || []).map((b) => {
+      if (b.translate) {
+        return t(b.title);
+      } else {
+        return b.title;
+      }
+    });
+  }
+  if (!route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length <= 0) {
+    return null;
+  } else if (route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length > 0) {
+    return (route.meta?.breadcrumbs || []).map((b) => {
+      if (b.translate) {
+        return {
+          ...b,
+          title: t(b.title),
+        };
+      } else {
+        return {
+          ...b,
+          title: b.title,
+        };
+      }
+    });
+  }
+  return null;
+});
+</script>

+ 261 - 231
src/components/layout/LeftMenuLayout.vue

@@ -5,71 +5,73 @@
     show-if-above
     no-swipe-close
     no-swipe-open
-    :width="250"
-    :mini-width="64"
+    :width="214"
+    :mini-width="60"
     :breakpoint="500"
     :mini="miniState"
     :behavior="'desktop'"
     class="detached-container"
   >
-    <div class="column full-height q-pa-sm no-wrap">
-      <div
-        v-if="!$q.screen.lt.md"
-        class="toggle-button-wrapper absolute"
-        style="top: 50%; right: -32px; z-index: 1"
-      >
-        <q-btn
-          flat
-          round
-          size="sm"
-          padding="8px 8px"
-          @click="miniState = !miniState"
+    <div class="column full-height no-wrap">
+      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
+        <div
+          class="flex flex-center full-width q-pa-sm"
+          style="height: 60px"
         >
-          <q-icon
-            :name="miniState ? 'mdi-chevron-right' : 'mdi-chevron-left'"
+          <q-img
+            v-if="!miniState"
+            :src="Logo"
+            style="width: 92px; height: 32px"
           />
-          <q-tooltip
-            anchor="center right"
-            self="center left"
-            :offset="[10, 10]"
-            >{{
-              miniState
-                ? $t("ui.navigation.expand_menu")
-                : $t("ui.navigation.collapse_menu")
-            }}</q-tooltip
-          >
-        </q-btn>
+          <q-img v-else :src="Logo" style="width: 32px" />
+        </div>
       </div>
 
-      <q-list class="column no-wrap">
-        <template v-for="item in navigationItems">
-          <template v-if="item.permission">
-            <q-item
-              v-if="item.type === 'single'"
-              :key="item.name"
-              v-ripple
-              clickable
-              exact-active-class="menu-selected"
-              exact
-              active-class="menu-selected"
-              :to="{ name: item.name }"
-              class="q-my-xs"
+      <div class="column full-height no-wrap">
+        <div
+          v-if="!$q.screen.lt.md"
+          class="toggle-button-wrapper absolute"
+          style="top: 2px; right: -32px; z-index: 1"
+        >
+          <div @click="miniState = !miniState">
+            <q-icon
+              size="sm"
+              :name="
+                miniState
+                  ? 'mdi-page-layout-sidebar-right'
+                  : 'mdi-page-layout-sidebar-left'
+              "
+              color="primary"
+            />
+            <q-tooltip
+              anchor="center right"
+              self="center left"
+              :offset="[10, 10]"
+              >{{
+                miniState
+                  ? $t("ui.navigation.expand_menu")
+                  : $t("ui.navigation.collapse_menu")
+              }}</q-tooltip
             >
-              <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
+          </div>
+        </div>
+        <q-list class="column no-wrap">
+          <template v-for="(item, index) in navigation_store.navigationItems">
+            <template v-if="item.permission">
+              <q-item
+                v-if="item.type === 'single'"
+                :key="item.name"
+                v-ripple
+                clickable
+                :exact="item.name == 'HomePage'"
+                exact-active-class="menu-selected"
+                active-class="menu-selected"
+                :to="{ name: item.name }"
               >
-            </q-item>
-            <!-- Expansive Menu with children -->
-            <div v-else :key="item.title">
-              <template v-if="!miniState">
+                <q-item-section avatar>
+                  <q-icon :name="item.icon" style="font-size: 20px" />
+                </q-item-section>
+                <q-item-section>{{ $t(item.title) }}</q-item-section>
                 <q-tooltip
                   v-if="miniState"
                   anchor="center right"
@@ -77,58 +79,10 @@
                   :offset="[10, 10]"
                   >{{ $t(item.title) }}</q-tooltip
                 >
-                <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-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"
-                >
-                  <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-item>
+              <!-- Expansive Menu with children -->
+              <div v-else :key="item.title">
+                <template v-if="!miniState">
                   <q-tooltip
                     v-if="miniState"
                     anchor="center right"
@@ -136,169 +90,237 @@
                     :offset="[10, 10]"
                     >{{ $t(item.title) }}</q-tooltip
                   >
-                  <q-menu anchor="center right" self="top start">
-                    <q-list>
+                  <q-expansion-item
+                    v-model="isExpasionItemExpanded[index]"
+                    :class="{
+                      'menu-selected':
+                        childrenAreActive(item.childrens) &&
+                        !isExpasionItemExpanded[index],
+                    }"
+                  >
+                    <template #header>
+                      <q-item-section avatar>
+                        <q-icon :name="item.icon" style="font-size: 20px" />
+                      </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 item.childrens"
-                        :key="child.name"
                         v-ripple
-                        v-close-popup
                         clickable
                         :to="{ name: child.name }"
                         exact
                         exact-active-class="menu-selected"
+                        class="q-pl-lg"
                       >
                         <q-item-section avatar>
-                          <q-icon :name="child.icon" style="font-size: 18px" />
+                          <q-icon :name="child.icon" style="font-size: 20px" />
                         </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-selected': childrenAreActive(item.childrens),
+                    }"
+                  >
+                    <q-item-section avatar>
+                      <q-icon :name="item.icon" style="font-size: 20px" />
+                    </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
+                      class="menu-drawer"
+                      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"
+                          class="menu-drawer"
+                        >
+                          <q-item-section avatar>
+                            <q-icon
+                              :name="child.icon"
+                              style="font-size: 20px"
+                            />
+                          </q-item-section>
+                          <q-item-section>{{ $t(child.title) }}</q-item-section>
+                        </q-item>
+                      </q-list>
+                    </q-menu>
+                  </q-item>
+                </template>
+              </div>
+            </template>
           </template>
-        </template>
-      </q-list>
-      <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
-      </q-list>
-      <q-list class="q-mt-auto">
-        <q-item v-ripple clickable>
-          <div class="flex">
-            <q-item-section avatar>
-              <template #default>
-                <!-- <img
-                  :src="someAvatar()"
-                  alt="avatar"
-                  style="width: 20px; height: 20px; border-radius: 50%"
-                /> -->
+        </q-list>
+        <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
+        </q-list>
+        <q-list class="q-mt-auto">
+          <q-item v-ripple clickable @click="changeTheme">
+            <div class="flex">
+              <q-item-section avatar>
                 <q-icon
-                  name="mdi-account"
-                  color="primary"
+                  :name="
+                    $q.dark.isActive ? 'mdi-weather-sunny' : 'mdi-weather-night'
+                  "
                   style="font-size: 20px"
                 />
-              </template>
-            </q-item-section>
-            <q-item-section>{{ user_store.user.name }}</q-item-section>
-          </div>
-          <q-tooltip
-            v-if="miniState"
-            anchor="center right"
-            self="center left"
-            :offset="[10, 10]"
-            >{{ user_store.user.name }}</q-tooltip
+              </q-item-section>
+              <q-item-section>{{
+                $q.dark.isActive
+                  ? $t("common.terms.light")
+                  : $t("common.terms.dark")
+              }}</q-item-section>
+            </div>
+            <q-tooltip
+              v-if="miniState"
+              anchor="center right"
+              self="center left"
+              :offset="[10, 10]"
+              >{{
+                $q.dark.isActive
+                  ? $t("common.terms.light")
+                  : $t("common.terms.dark")
+              }}</q-tooltip
+            >
+          </q-item>
+          <q-item v-ripple clickable @click="logoutFn">
+            <div class="flex">
+              <q-item-section avatar>
+                <q-icon
+                  name="logout"
+                  color="negative"
+                  style="font-size: 20px"
+                />
+              </q-item-section>
+              <q-item-section>{{ $t("auth.logout") }}</q-item-section>
+            </div>
+            <q-tooltip
+              v-if="miniState"
+              anchor="center right"
+              self="center left"
+              :offset="[10, 10]"
+              >{{ $t("auth.logout") }}</q-tooltip
+            >
+          </q-item>
+          <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
+            <div class="flex full-width justify-center">
+              <q-img
+                :src="
+                  miniState || $q.screen.lt.md
+                    ? LogoSoftparMini
+                    : $q.dark.isActive
+                      ? LogoSoftparLight
+                      : LogoSoftparDark
+                "
+                style="width: 100%; height: 30px"
+                :style="
+                  miniState || $q.screen.lt.md
+                    ? 'max-width: 48px'
+                    : 'max-width: 100px'
+                "
+              />
+            </div>
+          </q-item>
+        </q-list>
+        <div
+          class="full-width text-center text-subtitle3 q-pb-xs cursor-pointer"
+          @click="gotoVersionPage()"
+        >
+          <span
+            v-if="!(miniState || $q.screen.lt.md)"
+            class="text-caption text-weight-light"
+            >{{ $t("common.terms.version") + " " + version }}</span
           >
-          <q-menu anchor="center right" self="top start">
-            <q-list class="column no-wrap overflow-hidden">
-              <q-item
-                v-ripple
-                v-close-popup
-                clickable
-                :to="{ name: 'ProfilePage' }"
-                exact
-                exact-active-class="menu-selected"
-              >
-                <div class="flex">
-                  <q-item-section avatar>
-                    <q-icon
-                      name="account_circle"
-                      color="primary"
-                      style="font-size: 18px"
-                    />
-                  </q-item-section>
-                  <q-item-section>{{
-                    $t("user.profile.singular")
-                  }}</q-item-section>
-                </div>
-              </q-item>
-              <q-item v-ripple clickable @click="logoutFn">
-                <div class="flex">
-                  <q-item-section avatar>
-                    <q-icon
-                      name="logout"
-                      color="negative"
-                      style="font-size: 18px"
-                    />
-                  </q-item-section>
-                  <q-item-section>{{ $t("auth.logout") }}</q-item-section>
-                </div>
-              </q-item>
-            </q-list>
-          </q-menu>
-        </q-item>
-        <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
-          <div class="flex full-width justify-center">
-            <q-img
-              :src="
-                miniState || $q.screen.lt.md
-                  ? LogoSoftparMini
-                  : $q.dark.isActive
-                    ? LogoSoftparLight
-                    : LogoSoftparDark
-              "
-              style="width: 100%; height: 30px; max-width: 114px"
-            />
-          </div>
-        </q-item>
-      </q-list>
-      <div class="full-width text-center text-subtitle3">
-        <span class="text-caption text-weight-light">{{ version }}</span>
+          <span v-else class="text-caption text-weight-light">{{
+            version
+          }}</span>
+        </div>
       </div>
     </div>
   </q-drawer>
 </template>
 <script setup>
-import { ref, watch, watchEffect } from "vue";
+import { ref, watch, watchEffect, onMounted } from "vue";
 import { useAuth } from "src/composables/useAuth";
 import { useRouter, useRoute } from "vue-router";
-import { userStore } from "src/stores/user";
 import { navigationStore } from "src/stores/navigation";
+import { useQuasar, Cookies } from "quasar";
+import { version } from "src/../package.json";
+
+import Logo from "src/assets/logo.png";
 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";
-import { Cookies } from "quasar";
-import { useQuasar } from "quasar";
 
-const $q = useQuasar();
 const { logout } = useAuth();
 const router = useRouter();
 const route = useRoute();
-const user_store = userStore();
-const { navigationItems } = navigationStore();
-
-const version = "0.0.1";
-
-const miniStateCookies = Cookies.get("miniState")
+const navigation_store = navigationStore();
+const $q = useQuasar();
 
+const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
+  ? "dark"
+  : "light";
+const miniStateCookies = Cookies.get("miniState");
 const miniState = ref(miniStateCookies === "true" ? true : false);
 
 const childrenAreActive = (children) => {
-  if (!children) return false;
-  return children.some((child) => {
-    return route.path.includes(child.path);
-  });
+  if (!Array.isArray(children) || children.length === 0) {
+    return false;
+  }
+  return children.some((child) => route?.name === child?.name);
 };
 
-// const someAvatar = () => {
-//   return "https://cdn.quasar.dev/img/avatar4.jpg";
-// };
-
-const isExpasionItemExpanded = ref(false);
+const isExpasionItemExpanded = ref([]);
 
 watchEffect(() => {
   if ($q.screen.lt.md) {
     miniState.value = true;
-    if (Array.isArray(isExpasionItemExpanded.value)) {
-      isExpasionItemExpanded.value.forEach((expansion, index) => {
-        isExpasionItemExpanded.value[index] = false;
-      });
-    } else {
-      isExpasionItemExpanded.value = false;
-    }
+    isExpasionItemExpanded.value.forEach((expansion, index) => {
+      isExpasionItemExpanded.value[index] = false;
+    });
   }
 });
 
+const changeTheme = async () => {
+  const theme = $q.cookies.get("theme") || systemTheme;
+  if (theme == "dark") {
+    $q.dark.set(false);
+  } else {
+    $q.dark.set(true);
+  }
+};
+
 const logoutFn = async () => {
   await logout();
   router.push({ name: "LoginPage" });
@@ -308,13 +330,26 @@ const openUrl = (url) => {
   window.open(url, "_blank");
 };
 
+const gotoVersionPage = () => {
+  router.push({ name: "SystemVersionsPage" });
+};
+
 watch(miniState, () => {
-  Cookies.set("miniState", miniState.value);
+  Cookies.set("miniState", miniState.value, { path: "/", sameSite: "Lax" });
+});
+
+onMounted(() => {
+  navigation_store.navigationItems.forEach((item, index) => {
+    if (childrenAreActive(item.childrens)) {
+      isExpasionItemExpanded.value[index] = true;
+    }
+  });
 });
 </script>
 
 <style lang="scss" scoped>
 @import "/src/css/quasar.variables.scss";
+
 .text-subtitle3 {
   font-size: 1.1rem !important;
   font-weight: 400 !important;
@@ -332,9 +367,4 @@ watch(miniState, () => {
 .toggle-button-wrapper:hover {
   transform: scale(1.1);
 }
-
-.menu-item--spaced {
-  margin-top: 5px;
-  margin-bottom: 5px;
-}
 </style>

+ 103 - 79
src/components/layout/LeftMenuLayoutMobile.vue

@@ -3,110 +3,139 @@
   <q-drawer
     v-bind="$attrs"
     v-model="leftDrawerOpen"
-    :width="250"
+    :width="214"
     :breakpoint="500"
     behavior="mobile"
     class="detached-container"
   >
-    <div class="column full-height q-pa-sm no-wrap">
-      <q-list class="column no-wrap">
-        <template v-for="item in navigationItems">
-          <template v-if="item.permission">
-            <!-- Single Menu -->
-            <q-item
-              v-if="item.type === 'single'"
-              :key="item.name"
-              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="item.icon" style="font-size: 18px" />
-              </q-item-section>
-              <q-item-section>{{ $t(item.title) }}</q-item-section>
-            </q-item>
-            <!-- Expansive Menu with children -->
-            <q-expansion-item
-              v-else
-              :key="item.name"
-              v-model="isExpasionItemExpanded"
-              header-class="menu-item--spaced"
-              :class="{
-                'menu-selected':
-                  childrenAreActive(item.children) && !isExpasionItemExpanded,
-              }"
-            >
-              <template #header>
+    <div class="column full-height no-wrap">
+      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
+        <div
+          class="flex flex-center full-width q-pa-sm"
+          style="height: 50px"
+        >
+          <q-img :src="Logo" style="max-width: 92px" />
+        </div>
+      </div>
+
+      <div class="column full-height no-wrap">
+        <q-list class="column no-wrap">
+          <template v-for="item in navigation_store.navigationItems">
+            <template v-if="item.permission">
+              <!-- Single Menu -->
+              <q-item
+                v-if="item.type === 'single'"
+                :key="item.name"
+                v-ripple
+                clickable
+                exact-active-class="menu-selected"
+                active-class="menu-selected"
+                :exact="item.name == 'HomePage'"
+                :to="{ name: item.name }"
+              >
                 <q-item-section avatar>
-                  <q-icon :name="item.icon" style="font-size: 18px" />
+                  <q-icon :name="item.icon" style="font-size: 20px" />
                 </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-ripple
-                  clickable
-                  :to="{ name: child.name }"
-                  exact
-                  exact-active-class="menu-selected"
-                  class="menu-item--spaced q-pl-lg"
-                >
+              </q-item>
+              <!-- Expansive Menu with children -->
+              <q-expansion-item
+                v-else
+                :key="item.icon"
+                v-model="isExpasionItemExpanded"
+                :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: 20px" />
                   </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="q-pl-lg"
+                  >
+                    <q-item-section avatar>
+                      <q-icon :name="child.icon" style="font-size: 20px" />
+                    </q-item-section>
+                    <q-item-section>{{ $t(child.title) }}</q-item-section>
+                  </q-item>
+                </div>
+              </q-expansion-item>
+            </template>
           </template>
-        </template>
-      </q-list>
+        </q-list>
 
-      <q-list class="q-mt-auto">
-        <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
-          <div class="flex full-width justify-center">
-            <q-img
-              :src="$q.dark.isActive ? LogoSoftparLight : LogoSoftparDark"
-              style="width: 100%; height: 30px; max-width: 114px"
-            />
-          </div>
-        </q-item>
-      </q-list>
-      <div class="full-width text-center text-subtitle3">
-        <span class="text-caption text-weight-light">1.0.0</span>
+        <q-list class="q-mt-auto">
+          <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
+            <div class="flex full-width justify-center">
+              <q-img
+                :src="$q.dark.isActive ? LogoSoftparLight : LogoSoftparDark"
+                style="width: 100%; height: 30px; max-width: 114px"
+              />
+            </div>
+          </q-item>
+        </q-list>
+        <div
+          class="full-width text-center text-subtitle3 cursor-pointer"
+          @click="gotoVersionPage()"
+        >
+          <span class="text-caption text-weight-light">{{
+            $t("common.terms.version") + " " + version
+          }}</span>
+        </div>
       </div>
     </div>
   </q-drawer>
 </template>
 
 <script setup>
-import { ref } from "vue";
-import { useRoute } from "vue-router";
+import { ref, onMounted } from "vue";
+import { useRoute, useRouter } from "vue-router";
 import { navigationStore } from "src/stores/navigation";
+import { version } from "src/../package.json";
+
+import Logo from "src/assets/logo.png";
 import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
+
+const router = useRouter();
 const route = useRoute();
+const navigation_store = navigationStore();
 
 const leftDrawerOpen = defineModel({ type: Boolean });
-
-const { navigationItems } = navigationStore();
+const isExpasionItemExpanded = ref([]);
 
 const childrenAreActive = (children) => {
-  if (!children) return false;
-  return children.some((child) => {
-    return route.path.includes(child.path);
-  });
+  if (!Array.isArray(children) || children.length === 0) {
+    return false;
+  }
+  return children.some((child) => route?.name === child?.name);
 };
 
-const isExpasionItemExpanded = ref(false);
-
 const openUrl = (url) => {
   window.open(url, "_blank");
 };
+
+const gotoVersionPage = () => {
+  router.push({ name: "SystemVersionsPage" });
+};
+
+onMounted(() => {
+  navigation_store.navigationItems.forEach((item, index) => {
+    if (childrenAreActive(item.childrens)) {
+      isExpasionItemExpanded.value[index] = true;
+    }
+  });
+});
 </script>
 
 <style lang="scss" scoped>
@@ -120,9 +149,4 @@ const openUrl = (url) => {
   background-color: rgba($primary, 0.1);
   color: $primary;
 }
-
-.menu-item--spaced {
-  margin-top: 5px;
-  margin-bottom: 5px;
-}
 </style>

+ 13 - 21
src/components/regions/CitySelect.vue → src/components/selects/CitySelect.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-select
+  <DefaultSelect
     v-model="selectedCity"
     v-bind="$attrs"
     use-input
@@ -8,11 +8,8 @@
     clearable
     :options="cityOptions"
     :label
-    :rules
     :loading
-    :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.city')"
-    :error
-    :error-message
+    :placeholder
     @filter="filterFn"
   >
     <template #no-option>
@@ -22,7 +19,7 @@
         </q-item-section>
       </q-item>
     </template>
-  </q-select>
+  </DefaultSelect>
 </template>
 
 <script setup>
@@ -30,10 +27,11 @@ import { getCities } from "src/api/city";
 import { ref, onMounted, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 const emit = defineEmits(["selectedStateId"]);
 
-const { state, label, rules, initialId, country } = defineProps({
+const { state, label, initialId, country, placeholder } = defineProps({
   // This country prop is here for future use, maybe
   country: {
     type: Object,
@@ -54,28 +52,23 @@ const { state, label, rules, initialId, country } = defineProps({
     type: String,
     default: () => useI18n().t("ui.navigation.city"),
   },
-  rules: {
-    type: Array,
-    default: () => [],
+  placeholder: {
+    type: String,
+    default: () =>
+      useI18n().t("common.actions.search") +
+      " " +
+      useI18n().t("ui.navigation.city"),
   },
   initialId: {
     type: Number,
     required: false,
     default: null,
   },
-  error: {
-    type: Boolean,
-    default: false,
-  },
-  errorMessage: {
-    type: String,
-    default: "",
-  },
 });
 
 const selectedCity = defineModel({ type: Object });
 
-const loading = ref(false);
+const loading = ref(true);
 const baseOptions = ref([]);
 const cityOptions = ref([]);
 
@@ -143,7 +136,6 @@ watch(selectedCity, () => {
 
 onMounted(async () => {
   try {
-    loading.value = true;
     const baseCities = await getCities();
     baseOptions.value = baseCities.map((city) => ({
       label: city.name,
@@ -155,7 +147,7 @@ onMounted(async () => {
       selectCityById(initialId);
     }
   } catch (e) {
-    console.log(e);
+    console.error(e);
   } finally {
     loading.value = false;
   }

+ 17 - 27
src/components/regions/CountrySelect.vue → src/components/selects/CountrySelect.vue

@@ -1,20 +1,15 @@
 <template>
-  <q-select
+  <DefaultSelect
     v-model="selectedCountry"
     v-bind="$attrs"
     use-input
     hide-selected
     fill-input
     clearable
+    :label
+    :loading
+    :placeholder
     :options="countryOptions"
-    :label="label"
-    :loading="loading"
-    :placeholder="
-      $t('common.actions.search') + ' ' + $t('ui.navigation.country')
-    "
-    :rules="rules"
-    :error
-    :error-message
     @filter="filterFn"
   >
     <template #no-option>
@@ -24,41 +19,37 @@
         </q-item-section>
       </q-item>
     </template>
-  </q-select>
+  </DefaultSelect>
 </template>
 
 <script setup>
 import { getCountries } from "src/api/country";
 import { ref, onMounted } from "vue";
 import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const { label, rules, initialId } = defineProps({
-  label: {
-    type: String,
-    default: () => useI18n().t("ui.navigation.country"),
-  },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
+const { initialId, placeholder } = defineProps({
   initialId: {
     type: Number,
     required: false,
     default: null,
   },
-  error: {
-    type: Boolean,
-    default: false,
+  placeholder: {
+    type: String,
+    default: () =>
+      useI18n().t("common.actions.search") +
+      " " +
+      useI18n().t("ui.navigation.country"),
   },
-  errorMessage: {
+  label: {
     type: String,
-    default: "",
+    default: () => useI18n().t("ui.navigation.country"),
   },
 });
 
 const selectedCountry = defineModel({ type: Object });
 
-const loading = ref(false);
+const loading = ref(true);
 const baseCountry = ref([]);
 const countries = ref([]);
 const countryOptions = ref([]);
@@ -94,7 +85,6 @@ const selectCountryById = (id) => {
 
 onMounted(async () => {
   try {
-    loading.value = true;
     baseCountry.value = await getCountries();
     countryOptions.value = baseCountry.value.map((country) => ({
       label: country.name,
@@ -104,7 +94,7 @@ onMounted(async () => {
       selectCountryById(initialId);
     }
   } catch (e) {
-    console.log(e);
+    console.error(e);
   } finally {
     loading.value = false;
   }

+ 21 - 37
src/components/regions/StateSelect.vue → src/components/selects/StateSelect.vue

@@ -1,5 +1,5 @@
 <template>
-  <q-select
+  <DefaultSelect
     v-model="selectedState"
     v-bind="$attrs"
     use-input
@@ -9,10 +9,7 @@
     :options="stateOptions"
     :label
     :loading
-    :placeholder="$t('common.actions.search') + ' ' + $t('ui.navigation.state')"
-    :rules
-    :error
-    :error-message
+    :placeholder
     @filter="filterFn"
   >
     <template #no-option>
@@ -22,7 +19,7 @@
         </q-item-section>
       </q-item>
     </template>
-  </q-select>
+  </DefaultSelect>
 </template>
 
 <script setup>
@@ -30,46 +27,40 @@ import { getStates } from "src/api/state";
 import { ref, onMounted, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 const emit = defineEmits(["selectedCountryId"]);
 
-const { country, label, rules, initialId } = defineProps({
+const { country, initialId, placeholder } = defineProps({
   country: {
     type: Object,
     required: false,
-    default: () => {
-      return {
-        label: "Brasil",
-        value: 1,
-      };
-    },
+    default: () => ({
+      label: "Brasil",
+      value: 1,
+    }),
+  },
+  placeholder: {
+    type: String,
+    default: () =>
+      useI18n().t("common.actions.search") +
+      " " +
+      useI18n().t("ui.navigation.state"),
   },
   label: {
     type: String,
     default: () => useI18n().t("ui.navigation.state"),
   },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
   initialId: {
     type: Number,
     required: false,
     default: null,
   },
-  error: {
-    type: Boolean,
-    default: false,
-  },
-  errorMessage: {
-    type: String,
-    default: "",
-  },
 });
 
 const selectedState = defineModel({ type: Object });
 
-const loading = ref(false);
+const loading = ref(true);
 const baseOptions = ref([]);
 const stateOptions = ref([]);
 
@@ -86,23 +77,17 @@ const filterFn = (val, update) => {
 };
 
 const selectStateById = async (id) => {
-  if (selectedState.value?.value === id) {
-    return;
-  }
+  if (selectedState.value?.value === id) return;
   selectedState.value = baseOptions.value.find((state) => state.value === id);
 };
 
 const selectStateByName = (name) => {
-  if (selectedState.value?.label === name) {
-    return;
-  }
+  if (selectedState.value?.label === name) return;
   selectedState.value = baseOptions.value.find((state) => state.label === name);
 };
 
 const selectStateByCode = (code) => {
-  if (selectedState.value?.code === code) {
-    return;
-  }
+  if (selectedState.value?.code === code) return;
   selectedState.value = baseOptions.value.find((state) => state.code === code);
 };
 
@@ -140,7 +125,6 @@ watch(selectedState, () => {
 
 onMounted(async () => {
   try {
-    loading.value = true;
     const baseStates = await getStates();
     baseOptions.value = baseStates.map((state) => ({
       label: state.name,
@@ -153,7 +137,7 @@ onMounted(async () => {
       selectStateById(initialId);
     }
   } catch (e) {
-    console.log(e);
+    console.error(e);
   } finally {
     loading.value = false;
   }

+ 13 - 33
src/pages/users/components/UserTypeSelect.vue → src/components/selects/UserTypeSelect.vue

@@ -1,55 +1,38 @@
 <template>
-  <q-select
-    v-bind="$attrs"
+  <DefaultSelect
     v-model="selectedUserType"
+    v-bind="$attrs"
     use-input
     hide-selected
     fill-input
+    clearable
     :options="filteredOptions"
-    :clearable
-    :loading
-    :readonly
+    :loading="isLoading"
+    :placeholder
     :label
-    :rules
-    :error
-    :error-message
     @filter="filterFn"
   />
 </template>
+
 <script setup>
 import { onMounted, ref } from "vue";
 import { userTypes } from "src/api/user";
 import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const { label, rules, type } = defineProps({
-  label: {
+const { placeholder, label, type } = defineProps({
+  placeholder: {
     type: String,
     default: () => useI18n().t("common.ui.misc.type"),
   },
-  rules: {
-    type: Array,
-    default: () => [],
+  label: {
+    type: String,
+    default: () => useI18n().t("common.ui.misc.type"),
   },
   type: {
     type: String,
     default: null,
   },
-  clearable: {
-    type: Boolean,
-    default: true,
-  },
-  readonly: {
-    type: Boolean,
-    default: false,
-  },
-  error: {
-    type: Boolean,
-    default: false,
-  },
-  errorMessage: {
-    type: String,
-    default: "",
-  },
 });
 
 const selectedUserType = defineModel({
@@ -57,8 +40,7 @@ const selectedUserType = defineModel({
 });
 const userTypeOptions = ref([]);
 const filteredOptions = ref([]);
-const isLoading = ref(false);
-const searchQuery = ref("");
+const isLoading = ref(true);
 
 const selectUserByValue = (value) => {
   selectedUserType.value = userTypeOptions.value.find(
@@ -67,7 +49,6 @@ const selectUserByValue = (value) => {
 };
 
 const filterFn = (val, update) => {
-  searchQuery.value = val;
   update(() => {
     if (val === "") {
       filteredOptions.value = userTypeOptions.value;
@@ -82,7 +63,6 @@ const filterFn = (val, update) => {
 
 onMounted(async () => {
   try {
-    isLoading.value = true;
     const response = await userTypes();
     userTypeOptions.value = Object.entries(response).map(([key, value]) => ({
       label: value,

+ 2 - 1
src/composables/useInputRules.js

@@ -57,13 +57,14 @@ export const useInputRules = () => {
     },
   };
 
+  inputRules.required.$id = 'required'
+
   return {
     inputRules,
   };
 };
 
 function isValidCPF(cpf) {
-  console.log("isValidCPF", cpf);
   if (!cpf) return false;
   cpf = cpf.replace(/[^\d]+/g, "");
   if (cpf.length !== 11) return false;

+ 86 - 2
src/css/app.scss

@@ -22,6 +22,10 @@
   }
 }
 
+.q-item__section--avatar {
+  min-width: 0;
+}
+
 .q-toolbar {
   position: relative;
   padding: 0 12px;
@@ -33,8 +37,9 @@
   margin: 16px !important;
   margin-bottom: 16px !important;
   margin-left: 10px !important;
-  border-radius: 6px !important;
+  border-radius: 8px !important;
   transition: all;
+  color: #8b8b8b;
 }
 
 body.body--light {
@@ -46,10 +51,47 @@ body.body--light {
     background: #{map.get($colors, "surface")};
   }
 
+  .q-dialog {
+    .q-card {
+      background: #{map.get($colors, "surface-dark")};
+    }
+  }
+
   background: #{map.get($colors, "page")} !important;
+
+  .q-list--dark,
+  .q-item--dark:not(.q-item--active) {
+    color: black;
+  }
+
+  .q-field--dark {
+    .q-field__control:not(.text-negative) {
+      .q-field__marginal {
+        color: rgba(0, 0, 0, 0.54);
+      }
+      & ~ .q-field__bottom {
+        color: rgba(0, 0, 0, 0.54);
+      }
+    }
+    &:not(.q-field--highlighted)
+      .q-field__control:not(.text-negative)
+      .q-field__label {
+      color: rgba(0, 0, 0, 0.54);
+    }
+  }
+
+  .card-ring {
+    box-shadow: 0 0 0 1px #c0c0c0c0 !important;
+  }
+
+  .menu-drawer {
+    color: #8b8b8b;
+  }
 }
 
 body.body--dark {
+  background: #{map.get($colors-dark, "page")};
+
   .q-drawer:has(.detached-container) {
     background: #{map.get($colors-dark, "surface")} !important;
   }
@@ -58,7 +100,9 @@ body.body--dark {
     background: #{map.get($colors-dark, "surface")};
   }
 
-  background: #{map.get($colors-dark, "page")};
+  .card-ring {
+    box-shadow: 0 0 0 1px #505050 !important;
+  }
 }
 
 .q-card__actions .q-btn {
@@ -90,3 +134,43 @@ input[type="number"]::-webkit-outer-spin-button {
     display: none;
   }
 }
+
+.q-field__control {
+  background: #fff !important;
+}
+
+.q-field--dark .q-field__native {
+  color: black;
+}
+
+.q-field--dark .q-field__native,
+.q-field--dark .q-field__prefix,
+.q-field--dark .q-field__suffix,
+.q-field--dark .q-field__input {
+  color: black;
+}
+
+.q-field--dark:not(.q-field--highlighted) .q-field__label,
+.q-field--dark .q-field__marginal,
+.q-field--dark {
+  color: black;
+}
+
+.q-field--standout.q-field--rounded .q-field__control {
+  border-radius: 8px;
+  box-shadow: 0 0 0 1px #c0c0c0c0;
+}
+
+.q-btn--rectangle {
+  border-radius: 8px;
+}
+
+.q-table__select {
+  .q-field__inner {
+    .q-field__control {
+      padding-left: 6px;
+      border-radius: 8px;
+      overflow: hidden;
+    }
+  }
+}

+ 7 - 9
src/css/table.scss

@@ -1,21 +1,25 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
 .softpar-table {
-  padding-left: 16px !important;
-  padding-right: 16px !important;
-
   .body--dark & {
     --table-bg-color: #{map.get($colors-dark, "surface")}; // Using our dark background
     --table-border-color: #{map.get($colors-dark, "surface-light")}; // Darker border
     --table-header-color: #{map.get($colors-dark, "text")}; // Light text for dark mode
+    --table-ring-color: #505050;
   }
 
   .body--light & {
     --table-bg-color: #{map.get($colors, "surface")}; // Light background
     --table-border-color: #{map.get($colors, "surface-light")}; // Border color
     --table-header-color: #{map.get($colors, "text")}; // Dark text for light mode
+    --table-ring-color: #c0c0c0c0;
   }
 
+  padding-left: 16px !important;
+  padding-right: 16px !important;
+  border-radius: 8px !important;
+  box-shadow: 0 0 0 1px var(--table-ring-color);
+
   :deep(.q-table) {
     thead tr:first-child th {
       background-color: $primary !important;
@@ -40,12 +44,6 @@
     background-color: var(--table-bg-color) !important;
   }
 
-  .q-table__middle {
-    border: 0.5px solid var(--table-border-color);
-    box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.15);
-    border-radius: 4px;
-  }
-
   .q-table__top {
     padding-top: 16px;
     padding-left: 0px;

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

@@ -1,6 +1,27 @@
 {
+  "page": {
+    "city": {
+      "description": "View, edit, and add cities to the system"
+    },
+    "state": {
+      "description": "View, edit, and add states to the system"
+    },
+    "country": {
+      "description": "View, edit, and add countries to the system"
+    },
+    "users": {
+      "description": "View, edit, and add users with system access"
+    },
+    "system-dashboard": {
+      "description": "General vision of the system"
+    },
+    "versions": {
+      "description": "Informations about updates, fixes and changes for each system version"
+    }
+  },
   "common": {
     "actions": {
+      "import": "Import",
       "save": "Save",
       "cancel": "Cancel",
       "edit": "Edit",
@@ -16,10 +37,13 @@
       "copy_paste_code": "Copy and paste the code below to make the payment"
     },
     "terms": {
+      "actions": "Actions",
       "name": "Name",
       "email": "Email",
+      "branch": "Branch",
       "password": "Password",
       "description": "Description",
+      "contact_info": "Contact Info",
       "date": "Date",
       "start_date": "Start Date",
       "end_date": "End Date",
@@ -29,6 +53,7 @@
       "price": "Price",
       "quantity": "Quantity",
       "city": "City",
+      "list": "List",
       "state": "State",
       "country": "Country",
       "address": "Address",
@@ -43,6 +68,8 @@
       "cep": "ZIP Code",
       "order_number": "Order Number",
       "order_amount": "Order Amount",
+      "owner_name": "Owner name",
+      "client_name": "Client name",
       "total_amount": "Total Amount",
       "payment": "Payment",
       "payment_method": "Payment Method",
@@ -54,6 +81,8 @@
       "avatar": "Avatar",
       "banner": "Banner",
       "logo": "Logo",
+      "light": "Light",
+      "dark": "Dark",
       "media": "Media",
       "month": "Month",
       "week": "Week",
@@ -101,7 +130,8 @@
       "table": {
         "rows_per_page": "Rows per page",
         "of": "of",
-        "to": "to"
+        "to": "to",
+        "records_found": "record(s) found"
       },
       "messages": {
         "copied_to_clipboard": "Copied to clipboard",
@@ -126,6 +156,7 @@
     }
   },
   "auth": {
+    "sign-in": "Sign in",
     "login": "Login",
     "logout": "Logout",
     "registration": "Registration",
@@ -255,6 +286,9 @@
   "ui": {
     "navigation": {
       "collapse_menu": "Collapse menu",
+      "dashboards": "Dashboards",
+      "home": "Home",
+      "versions": "Versions",
       "expand_menu": "Expand menu",
       "dashboard": "Dashboard",
       "explore": "Explore",

+ 35 - 1
src/i18n/locales/es.json

@@ -1,6 +1,27 @@
 {
+  "page": {
+    "city": {
+      "description": "Consulte, edite y agregue las ciudades del sistema"
+    },
+    "state": {
+      "description": "Consulte, edite y agregue los estados del sistema"
+    },
+    "country": {
+      "description": "Consulte, edite y agregue los países del sistema"
+    },
+    "users": {
+      "description": "Consulte, edite y agregue usuarios con acceso al sistema"
+    },
+    "system-dashboard": {
+      "description": "Visão general del sistema"
+    },
+    "versions": {
+      "description": "Información sobre actualizaciones, correcciones y cambios en cada versión del sistema"
+    }
+  },
   "common": {
     "actions": {
+      "import": "Import",
       "save": "Guardar",
       "cancel": "Cancelar",
       "edit": "Editar",
@@ -16,8 +37,11 @@
       "copy_paste_code": "Copie y pegue el código a continuación para realizar el pago"
     },
     "terms": {
+      "actions": "Ações",
       "name": "Nombre",
       "email": "Correo electrónico",
+      "contact_info": "Contato",
+      "branch": "Branch",
       "password": "Contraseña",
       "description": "Descripción",
       "date": "Fecha",
@@ -29,6 +53,7 @@
       "price": "Precio",
       "quantity": "Cantidad",
       "city": "Ciudad",
+      "list": "Lista",
       "state": "Estado/Provincia",
       "country": "País",
       "address": "Dirección",
@@ -43,6 +68,8 @@
       "cep": "Código Postal",
       "order_number": "Número de pedido",
       "order_amount": "Monto del pedido",
+      "owner_name": "Nome do dono",
+      "client_name": "Nome do cliente",
       "total_amount": "Monto total",
       "payment": "Pago",
       "payment_method": "Método de pago",
@@ -54,6 +81,8 @@
       "avatar": "Avatar",
       "banner": "Banner",
       "logo": "Logo",
+      "light": "Claro",
+      "dark": "Escuro",
       "media": "Media",
       "month": "Mes",
       "week": "Semana",
@@ -101,7 +130,8 @@
       "table": {
         "rows_per_page": "Filas por página",
         "of": "de",
-        "to": "a"
+        "to": "a",
+        "records_found": "registro(s) encontrado(s)"
       },
       "messages": {
         "copied_to_clipboard": "Copiado al portapapeles",
@@ -126,6 +156,7 @@
     }
   },
   "auth": {
+    "sign-in": "Iniciar sesión",
     "login": "Iniciar sesión",
     "logout": "Cerrar sesión",
     "registration": "Registro",
@@ -255,6 +286,9 @@
   "ui": {
     "navigation": {
       "collapse_menu": "Contraer menú",
+      "dashboards": "Dashboards",
+      "home": "Início",
+      "versions": "Versiónes",
       "expand_menu": "Expandir menú",
       "dashboard": "Panel",
       "explore": "Explorar",

+ 40 - 6
src/i18n/locales/pt.json

@@ -1,10 +1,31 @@
 {
+  "page": {
+    "city": {
+      "description": "Consulte edite e adicione os cidades do sistema"
+    },
+    "state": {
+      "description": "Consulte edite e adicione os estados do sistema"
+    },
+    "country": {
+      "description": "Consulte edite e adicione os paises do sistema"
+    },
+    "users": {
+      "description": "Consulte edite e adicione usuarios com acesso ao sistema"
+    },
+    "system-dashboard": {
+      "description": "Visão geral do sistema"
+    },
+    "versions": {
+      "description": "Informações sobre atualizações, correções e mudanças em cada versão do sistema"
+    }
+  },
   "common": {
     "actions": {
+      "import": "Importar",
       "save": "Salvar",
       "cancel": "Cancelar",
       "edit": "Editar",
-      "add": "Adicionar",
+      "add": "Cadastrar",
       "search": "Buscar",
       "delete": "Excluir",
       "view": "Visualizar",
@@ -16,8 +37,11 @@
       "copy_paste_code": "Copie e cole o código abaixo para efetuar o pagamento"
     },
     "terms": {
+      "actions": "Ações",
+      "contact_info": "Contato",
       "name": "Nome",
       "email": "E-mail",
+      "branch": "Ramo de atuaçao",
       "password": "Senha",
       "description": "Descrição",
       "date": "Data",
@@ -29,6 +53,7 @@
       "price": "Preço",
       "quantity": "Quantidade",
       "city": "Cidade",
+      "list": "Lista",
       "state": "Estado",
       "country": "País",
       "address": "Endereço",
@@ -43,6 +68,8 @@
       "cep": "CEP",
       "order_number": "Número do Pedido",
       "order_amount": "Valor do Pedido",
+      "owner_name": "Nome do dono",
+      "client_name": "Nome do cliente",
       "total_amount": "Valor Total",
       "payment": "Pagamento",
       "payment_method": "Método de Pagamento",
@@ -54,6 +81,8 @@
       "avatar": "Avatar",
       "banner": "Banner",
       "logo": "Logo",
+      "light": "Claro",
+      "dark": "Escuro",
       "media": "Mídia",
       "month": "Mês",
       "week": "Semana",
@@ -101,7 +130,8 @@
       "table": {
         "rows_per_page": "Linhas por página",
         "of": "de",
-        "to": "a"
+        "to": "a",
+        "records_found": "registro(s) encontrado(s)"
       },
       "messages": {
         "copied_to_clipboard": "Copiado para a área de transferência",
@@ -126,6 +156,7 @@
     }
   },
   "auth": {
+    "sign-in": "Entrar",
     "login": "Login",
     "logout": "Sair",
     "registration": "Cadastro",
@@ -255,6 +286,9 @@
   "ui": {
     "navigation": {
       "collapse_menu": "Recolher menu",
+      "dashboards": "Dashboards",
+      "home": "Início",
+      "versions": "Versões",
       "expand_menu": "Expandir menu",
       "dashboard": "Painel",
       "explore": "Explorar",
@@ -272,12 +306,12 @@
       "users": "Usuários",
       "profile": "Perfil",
       "interests": "Interesses",
-      "registration": "Cadastro",
+      "registration": "Cadastros",
       "wallet": "Carteira",
       "settings": "Configurações",
-      "city": "Cidade",
-      "state": "Estado",
-      "country": "País",
+      "city": "Cidades",
+      "state": "Estados",
+      "country": "Países",
       "exit": "Sair"
     }
   },

+ 36 - 55
src/layouts/MainLayout.vue

@@ -8,52 +8,26 @@
         style="border-radius: 6px !important"
       >
         <q-btn dense flat @click="toggleLeftDrawer">
-          <q-icon name="menu" :color="$q.dark.isActive ? 'white' : 'black'" />
-        </q-btn>
-        <q-btn dense flat>
-          <img
-            :src="someAvatar()"
-            alt="avatar"
-            style="width: 20px; height: 20px; border-radius: 50%"
+          <q-icon
+            name="menu"
+            :color="$q.dark.isActive ? 'white' : 'black'"
+            style="font-size: 20px"
           />
-          <q-menu anchor="center right" self="top start">
-            <q-list class="column no-wrap overflow-hidden">
-              <q-item
-                v-ripple
-                v-close-popup
-                clickable
-                :to="{ name: 'ProfilePage' }"
-                exact
-                exact-active-class="menu-selected"
-              >
-                <div class="flex">
-                  <q-item-section avatar>
-                    <q-icon
-                      name="account_circle"
-                      color="primary"
-                      style="font-size: 18px"
-                    />
-                  </q-item-section>
-                  <q-item-section>{{
-                    $t("user.profile.singular")
-                  }}</q-item-section>
-                </div>
-              </q-item>
-              <q-item v-ripple clickable @click="logoutFn">
-                <div class="flex">
-                  <q-item-section avatar>
-                    <q-icon
-                      name="logout"
-                      color="negative"
-                      style="font-size: 18px"
-                    />
-                  </q-item-section>
-                  <q-item-section>{{ $t("auth.logout") }}</q-item-section>
-                </div>
-              </q-item>
-            </q-list>
-          </q-menu>
         </q-btn>
+        <div>
+          <q-btn dense flat class="q-mr-sm" @click="changeTheme">
+            <q-icon
+              :color="$q.dark.isActive ? 'white' : 'black'"
+              :name="
+                $q.dark.isActive ? 'mdi-weather-sunny' : 'mdi-weather-night'
+              "
+              style="font-size: 20px"
+            />
+          </q-btn>
+          <q-btn dense flat @click="logoutFn">
+            <q-icon name="logout" color="negative" style="font-size: 20px" />
+          </q-btn>
+        </div>
       </q-toolbar>
     </q-header>
     <q-page-container>
@@ -83,27 +57,25 @@
 
 <script setup>
 import { ref, useTemplateRef, watch } from "vue";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
 import { useAuth } from "src/composables/useAuth";
-import { useRouter } from "vue-router";
+import { useQuasar } from "quasar";
+
 import LeftMenuLayout from "src/components/layout/LeftMenuLayout.vue";
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
 
-defineOptions({
-  name: "MainLayout",
-});
-
+const router = useRouter();
 const { logout } = useAuth();
 const route = useRoute();
+const $q = useQuasar();
+
 const leftDrawerOpen = ref(false);
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
-const router = useRouter();
 
 let oldValue = route.path;
-
-const someAvatar = () => {
-  return "https://cdn.quasar.dev/img/avatar4.jpg";
-};
+const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
+  ? "dark"
+  : "light";
 
 const logoutFn = async () => {
   await logout();
@@ -114,6 +86,15 @@ const toggleLeftDrawer = () => {
   leftDrawerOpen.value = !leftDrawerOpen.value;
 };
 
+const changeTheme = async () => {
+  const theme = $q.cookies.get("theme") || systemTheme;
+  if (theme == "dark") {
+    $q.dark.set(false);
+  } else {
+    $q.dark.set(true);
+  }
+};
+
 watch(route, (value) => {
   if (oldValue.path != value.path) {
     scrollAreaRef.value.setScrollPosition("vertical", 0, 0);

+ 0 - 136
src/pages/LoginPage.vue

@@ -1,136 +0,0 @@
-<template>
-  <q-page padding class="login-page">
-    <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("common.ui.messages.welcome") }}</div>
-      </div>
-
-      <q-form
-        ref="loginForm"
-        class="q-pa-md"
-        autocorrect="off"
-        autocapitalize="off"
-        autocomplete="off"
-        spellcheck="false"
-        @submit="submitLogin"
-      >
-        <q-card-section class="q-mt-sm">
-          <q-input
-            v-model="email"
-            filled
-            type="email"
-            label="Email"
-            lazy-rules
-            autofocus
-            :rules="[inputRules.required, inputRules.email]"
-          />
-
-          <DefaultPasswordInput
-            v-model="password"
-            :rules="[inputRules.required, inputRules.min(6)]"
-          />
-
-          <q-checkbox v-model="checkbox" label="Lembrar email" />
-        </q-card-section>
-
-        <q-card-actions align="right">
-          <q-btn
-            color="primary"
-            :label="$t('auth.login')"
-            size="md"
-            padding="md"
-            type="submit"
-            :loading="submitting"
-          >
-            <template #loading>
-              <q-spinner />
-            </template>
-          </q-btn>
-        </q-card-actions>
-      </q-form>
-    </q-card>
-  </q-page>
-</template>
-
-<script setup>
-import { ref, onMounted } from "vue";
-import { useQuasar } from "quasar";
-import { useAuth } from "src/composables/useAuth";
-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();
-
-const { inputRules } = useInputRules();
-const email = ref("");
-const password = ref(process.env.PASSWORD);
-const submitting = ref(false);
-const loginForm = ref(null);
-const checkbox = ref(false);
-
-const submitLogin = async () => {
-  try {
-    submitting.value = true;
-
-    const validate = await loginForm.value.validate();
-
-    if (!validate) {
-      return;
-    }
-
-    const email_storage = $q.cookies.get("email");
-
-    if (email_storage && !checkbox.value) {
-      $q.cookies.remove("email");
-    }
-    if (checkbox.value) {
-      $q.cookies.set("email", email.value, {
-        expires: "3d",
-        path: "/",
-        sameSite: "Lax",
-      });
-    }
-
-    await useAuth().login(email.value, password.value);
-
-    submitting.value = false;
-
-    router.push({ name: "DashboardPage" });
-  } catch (error) {
-    console.error(error);
-    submitting.value = false;
-  }
-};
-
-onMounted(() => {
-  const email_storage = $q.cookies.get("email");
-
-  if (email_storage) {
-    checkbox.value = true;
-    email.value = email_storage;
-  }
-
-  if (process.env.DEV && process.env.SENHA) {
-    password.value = process.env.SENHA;
-  }
-});
-</script>
-
-<style lang="scss" scoped>
-.login-page {
-  display: flex;
-  justify-content: center;
-  align-items: center;
-
-  .login-card {
-    width: 100%;
-    max-width: 500px;
-    border-radius: 12px;
-  }
-}
-</style>

+ 41 - 12
src/pages/city/CityPage.vue

@@ -1,20 +1,43 @@
 <template>
   <div>
-    <DefaultHeaderPage />
+    <DefaultHeaderPage>
+      <template #after>
+        <q-btn
+          color="primary"
+          padding="8px 8px"
+          :label="$t('common.actions.add')"
+          icon="mdi-plus"
+          class="q-mt-md"
+          @click="onAddItem"
+        />
+      </template>
+    </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"
-      />
+        :show-columns-select="false"
+        :title="
+          $t('common.terms.list') +
+          ' ' +
+          $t('common.ui.table.of') +
+          ' ' +
+          $t('ui.navigation.city')
+        "
+      >
+        <template #body-cell-actions="{ row }">
+          <q-btn
+            outline
+            style="width: 36px"
+            class="q-ml-auto q-mr-sm"
+            @click.prevent.stop="onRowClick(row)"
+          >
+            <q-icon name="mdi-file-edit-outline" />
+          </q-btn>
+        </template>
+      </DefaultTable>
     </div>
   </div>
 </template>
@@ -71,17 +94,21 @@ const columns = [
     name: "status",
     label: t("common.terms.status"),
     field: (row) =>
-      row.status == "ACTIVE" ? t("common.status.active") : t("common.status.inactive"),
+      row.status == "ACTIVE"
+        ? t("common.status.active")
+        : t("common.status.inactive"),
     align: "left",
     sortable: true,
   },
   {
     name: "actions",
+    label: t("common.terms.actions"),
+    align: "left",
     required: true,
   },
 ];
 
-const onRowClick = ({ row }) => {
+const onRowClick = (row) => {
   if (permission_store.getAccess("config.city", "edit") === false) {
     $q.loading.hide();
     $q.notify({
@@ -119,7 +146,9 @@ const onAddItem = () => {
     component: AddEditCityDialog,
     componentProps: {
       title: () =>
-        useI18n().t("common.actions.add") + " " + useI18n().t("ui.navigation.city"),
+        useI18n().t("common.actions.add") +
+        " " +
+        useI18n().t("ui.navigation.city"),
     },
   }).onOk(async (success) => {
     if (success) {

+ 28 - 28
src/pages/city/components/AddEditCityDialog.vue

@@ -3,56 +3,54 @@
     <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
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
             v-model="form.name"
-            :label="$t('common.terms.name')"
+            v-model:error="validationErrors.name"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.name"
-            :error-message="serverErrors?.name"
+            :label="$t('common.terms.name')"
+            :placeholder="'Nome completo da cidade'"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.name = null"
           />
           <CountrySelect
-            ref="countrySelectRef"
             v-model="selectedCountry"
-            :label="$t('ui.navigation.country')"
+            v-model:error="validationErrors.country_id"
+            :placeholder="'Selecione o pais desse estado'"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.country_id"
-            :error-message="serverErrors?.country_id"
-            :initial-id="city ? city.country_id : null"
+            :initial-id="form.country_id"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.country_id = null"
           />
           <StateSelect
             v-model="selectedState"
+            v-model:error="validationErrors.state_id"
             :country="selectedCountry"
             :initial-id="form.state_id"
-            :label="$t('ui.navigation.state')"
+            :placeholder="'Selecione o estado dessa cidade'"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.state_id"
-            :error-message="serverErrors?.state_id"
             class="col-md-6 col-12"
-            @selected-country-id="countrySelectRef.selectCountryById($event)"
-            @update:model-value="serverErrors.state_id = null"
+            @selected-country-id="countrySelectRef?.selectCountryById($event)"
           />
-          <q-select
+          <DefaultSelect
             v-model="selectedStatus"
-            :label="$t('common.terms.status')"
+            v-model:error="validationErrors.status"
             :options="statusOptions"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.status"
-            :error-message="serverErrors?.status"
+            :label="$t('common.terms.status')"
+            :placeholder="$t('common.terms.status')"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.status = null"
           />
         </q-card-section>
-        <q-card-actions align="center">
-          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+        <q-card-actions>
           <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
           <q-btn
             color="primary"
-            label="OK"
+            :label="city ? $t('common.actions.save') : $t('common.actions.add')"
             :type="'submit'"
             :loading="loading"
             :disable="!hasUpdatedFields"
@@ -72,8 +70,10 @@ import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import CountrySelect from "src/components/regions/CountrySelect.vue";
-import StateSelect from "src/components/regions/StateSelect.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import CountrySelect from "src/components/selects/CountrySelect.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([
   // REQUIRED; need to specify some events that your
@@ -110,7 +110,7 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
 
 const {
   loading,
-  serverErrors,
+  validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
   onSuccess: () => onDialogOK(true),

+ 38 - 11
src/pages/country/CountryPage.vue

@@ -1,20 +1,43 @@
 <template>
   <div>
-    <DefaultHeaderPage />
+    <DefaultHeaderPage>
+      <template #after>
+        <q-btn
+          color="primary"
+          padding="8px 8px"
+          :label="$t('common.actions.add')"
+          icon="mdi-plus"
+          class="q-mt-md"
+          @click="onAddItem"
+        />
+      </template>
+    </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"
-      />
+        :show-columns-select="false"
+        :title="
+          $t('common.terms.list') +
+          ' ' +
+          $t('common.ui.table.of') +
+          ' ' +
+          $t('ui.navigation.country')
+        "
+      >
+        <template #body-cell-actions="{ row }">
+          <q-btn
+            outline
+            style="width: 36px"
+            class="q-ml-auto q-mr-sm"
+            @click.prevent.stop="onRowClick(row)"
+          >
+            <q-icon name="mdi-file-edit-outline" />
+          </q-btn>
+        </template>
+      </DefaultTable>
     </div>
   </div>
 </template>
@@ -64,17 +87,21 @@ const columns = [
     name: "status",
     label: t("common.terms.status"),
     field: (row) =>
-      row.status == "ACTIVE" ? t("common.status.active") : t("common.status.inactive"),
+      row.status == "ACTIVE"
+        ? t("common.status.active")
+        : t("common.status.inactive"),
     align: "left",
     sortable: true,
   },
   {
     name: "actions",
+    label: t("common.terms.actions"),
+    align: "left",
     required: true,
   },
 ];
 
-const onRowClick = ({ row }) => {
+const onRowClick = (row) => {
   if (permission_store.getAccess("config.country", "edit") === false) {
     $q.loading.hide();
     $q.notify({

+ 24 - 18
src/pages/country/components/AddEditCountryDialog.vue

@@ -3,42 +3,46 @@
     <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
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
             v-model="form.name"
+            v-model:error="validationErrors.name"
             :label="$t('common.terms.name')"
+            :placeholder="'Nome comple do pais'"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.name"
-            :error-message="serverErrors?.name"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.name = null"
           />
-          <q-input
+          <DefaultInput
             v-model="form.code"
+            v-model:error="validationErrors.code"
             :label="$t('common.terms.code')"
+            :placeholder="'Ex. BR, PT...'"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.code"
-            :error-message="serverErrors?.code"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.code = null"
           />
-          <q-select
+          <DefaultSelect
             v-model="selectedStatus"
-            :label="$t('common.terms.status')"
+            v-model:error="validationErrors.status"
             :options="statusOptions"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.status"
-            :error-message="serverErrors?.status"
+            :label="$t('common.terms.status')"
+            :placeholder="$t('common.terms.status')"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.status = null"
           />
         </q-card-section>
-        <q-card-actions align="center">
-          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+        <q-card-actions>
           <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
           <q-btn
             color="primary"
-            label="OK"
+            :label="
+              country ? $t('common.actions.save') : $t('common.actions.add')
+            "
             :type="'submit'"
             :loading="loading"
             :disable="!hasUpdatedFields"
@@ -58,6 +62,8 @@ import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([
   // REQUIRED; need to specify some events that your
@@ -92,7 +98,7 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
 
 const {
   loading,
-  serverErrors,
+  validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
   onSuccess: () => onDialogOK(true),

+ 13 - 0
src/pages/home/HomePage.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="column flex-center flex-grow">
+    <span class="text-h4 text-primary q-mb-md">
+      {{ $t("common.ui.messages.welcome") + " " + $t("common.ui.table.to") }}
+    </span>
+    <div class="column flex-center full-width q-px-md">
+      <q-img :src="Logo" style="max-width: 430px" />
+    </div>
+  </div>
+</template>
+<script setup>
+import Logo from "src/assets/logo.png";
+</script>

+ 134 - 0
src/pages/login/LoginPage.vue

@@ -0,0 +1,134 @@
+<template>
+  <q-page class="column">
+    <div
+      flat
+      class="column justify-around items-center flex-grow full-width full-height z-top frosted-glass"
+    >
+      <div class="column flex-center full-width q-px-md">
+        <q-img :src="Logo" style="max-width: 650px" />
+        <div class="text-h5 q-mt-xl">{{ $t("auth.login") }}</div>
+      </div>
+
+      <q-form
+        ref="formRef"
+        class="full-width q-pa-md q-pb-xl"
+        style="max-width: 400px"
+        autocorrect="off"
+        autocapitalize="off"
+        autocomplete="off"
+        spellcheck="false"
+        @submit="onSubmit"
+      >
+        <DefaultInput
+          v-model="form.email"
+          v-model:error="validationErrors.email"
+          type="email"
+          lazy-rules
+          autofocus
+          :label="$t('common.terms.email')"
+          :rules="[inputRules.required, inputRules.email]"
+        />
+        <DefaultPasswordInput
+          v-model="form.password"
+          v-model:error="validationErrors.password"
+          :rules="[inputRules.required, inputRules.min(6)]"
+          :label="$t('common.terms.password')"
+        />
+        <q-checkbox
+          v-model="checkbox"
+          size="xs"
+          label="Lembrar email"
+          class="q-mb-md"
+          style="margin-left: -6px"
+        />
+        <div>
+          <q-btn
+            class="full-width"
+            color="primary"
+            :label="$t('auth.sign-in')"
+            size="md"
+            padding="sm"
+            type="submit"
+            :loading
+          >
+            <template #loading>
+              <q-spinner />
+            </template>
+          </q-btn>
+        </div>
+        <div style="height: 160px"></div>
+      </q-form>
+    </div>
+    <WavePattern class="absolute-top" />
+  </q-page>
+</template>
+
+<script setup>
+import { ref, onBeforeMount, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useAuth } from "src/composables/useAuth";
+import { useRouter } from "vue-router";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import Logo from "src/assets/logo.png";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import WavePattern from "./component/WavePattern.vue";
+
+const router = useRouter();
+const $q = useQuasar();
+
+const { inputRules } = useInputRules();
+const { login } = useAuth();
+
+const formRef = useTemplateRef("formRef");
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => router.push({ name: "HomePage" }),
+  formRef: formRef,
+});
+
+const form = ref({
+  email: null,
+  password: process.env.PASSWORD ?? null,
+});
+
+const checkbox = ref(false);
+
+const onSubmit = async () => {
+  await submitForm(() => login(form.value.email, form.value.password));
+  const email_storage = $q.cookies.get("email");
+  if (email_storage && !checkbox.value) {
+    $q.cookies.remove("email");
+  }
+  if (checkbox.value) {
+    $q.cookies.set("email", form.value.email, {
+      path: "/",
+      sameSite: "Lax",
+    });
+  }
+};
+
+onBeforeMount(() => {
+  const email_storage = $q.cookies.get("email");
+  if (email_storage) {
+    checkbox.value = true;
+    form.value.email = email_storage;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.login-page {
+  position: relative;
+}
+
+.frosted-glass {
+  backdrop-filter: blur(60px);
+}
+</style>

+ 231 - 0
src/pages/login/component/WavePattern.vue

@@ -0,0 +1,231 @@
+<template>
+  <div class="overflow-hidden full-height" v-bind="$attrs">
+    <div class="element-with-wave"></div>
+  </div>
+</template>
+<style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss";
+
+.element-with-wave {
+  position: relative;
+  background: rgba(map.get($colors-dark, "primary"), 1) !important;
+  margin-top: 60dvh;
+  width: 100%;
+  height: 100dvh;
+  transform: translateZ(0);
+}
+
+.element-with-wave::before {
+  content: "";
+  position: absolute;
+  top: -240px;
+  left: 0;
+  width: 100%;
+  height: 240px;
+  background-color: rgba(map.get($colors-dark, "primary"), 1) !important;
+  will-change: clip-path;
+  animation: organic-swell 20s ease-in-out infinite alternate;
+  clip-path: polygon(
+    0% 100%,
+    0% 50%,
+    5% 45%,
+    10% 55%,
+    18% 40%,
+    25% 60%,
+    33% 45%,
+    42% 60%,
+    50% 40%,
+    58% 60%,
+    67% 45%,
+    75% 60%,
+    82% 40%,
+    91% 60%,
+    100% 50%,
+    100% 100%
+  );
+}
+
+@keyframes organic-swell {
+  0% {
+    clip-path: polygon(
+      0% 100%,
+      0% 50%,
+      5% 45%,
+      10% 55%,
+      18% 40%,
+      25% 60%,
+      33% 45%,
+      42% 60%,
+      50% 40%,
+      58% 60%,
+      67% 45%,
+      75% 60%,
+      82% 40%,
+      91% 60%,
+      100% 50%,
+      100% 100%
+    );
+  }
+  12.5% {
+    clip-path: polygon(
+      0% 100%,
+      0% 10%,
+      5% 20%,
+      10% 15%,
+      18% 30%,
+      25% 40%,
+      33% 35%,
+      42% 50%,
+      50% 55%,
+      58% 65%,
+      67% 60%,
+      75% 70%,
+      82% 65%,
+      91% 75%,
+      100% 60%,
+      100% 100%
+    );
+  }
+  25% {
+    clip-path: polygon(
+      0% 100%,
+      0% 40%,
+      5% 30%,
+      10% 50%,
+      18% 20%,
+      25% 30%,
+      33% 15%,
+      42% 40%,
+      50% 50%,
+      58% 60%,
+      67% 55%,
+      75% 65%,
+      82% 60%,
+      91% 70%,
+      100% 55%,
+      100% 100%
+    );
+  }
+  37.5% {
+    clip-path: polygon(
+      0% 100%,
+      0% 30%,
+      5% 25%,
+      10% 35%,
+      18% 20%,
+      25% 25%,
+      33% 20%,
+      42% 30%,
+      50% 20%,
+      58% 35%,
+      67% 25%,
+      75% 30%,
+      82% 20%,
+      91% 35%,
+      100% 30%,
+      100% 100%
+    );
+  }
+  50% {
+    clip-path: polygon(
+      0% 100%,
+      0% 60%,
+      5% 70%,
+      10% 60%,
+      18% 50%,
+      25% 40%,
+      33% 30%,
+      42% 10%,
+      50% 20%,
+      58% 30%,
+      67% 40%,
+      75% 50%,
+      82% 60%,
+      91% 70%,
+      100% 60%,
+      100% 100%
+    );
+  }
+  62.5% {
+    clip-path: polygon(
+      0% 100%,
+      0% 80%,
+      5% 75%,
+      10% 85%,
+      18% 70%,
+      25% 80%,
+      33% 65%,
+      42% 75%,
+      50% 60%,
+      58% 50%,
+      67% 35%,
+      75% 25%,
+      82% 40%,
+      91% 20%,
+      100% 30%,
+      100% 100%
+    );
+  }
+  75% {
+    clip-path: polygon(
+      0% 100%,
+      0% 70%,
+      5% 85%,
+      10% 75%,
+      18% 90%,
+      25% 80%,
+      33% 85%,
+      42% 70%,
+      50% 80%,
+      58% 75%,
+      67% 85%,
+      75% 80%,
+      82% 90%,
+      91% 75%,
+      100% 80%,
+      100% 100%
+    );
+  }
+  87.5% {
+    clip-path: polygon(
+      0% 100%,
+      0% 55%,
+      5% 50%,
+      10% 55%,
+      18% 50%,
+      25% 55%,
+      33% 50%,
+      42% 55%,
+      50% 50%,
+      58% 55%,
+      67% 50%,
+      75% 55%,
+      82% 50%,
+      91% 55%,
+      100% 50%,
+      100% 100%
+    );
+  }
+  100% {
+    clip-path: polygon(
+      0% 100%,
+      0% 45%,
+      5% 40%,
+      10% 50%,
+      18% 35%,
+      25% 55%,
+      33% 40%,
+      42% 55%,
+      50% 35%,
+      58% 55%,
+      67% 40%,
+      75% 55%,
+      82% 35%,
+      91% 55%,
+      100% 45%,
+      100% 100%
+    );
+  }
+}
+</style>

+ 38 - 11
src/pages/state/StatePage.vue

@@ -1,20 +1,43 @@
 <template>
   <div>
-    <DefaultHeaderPage />
+    <DefaultHeaderPage>
+      <template #after>
+        <q-btn
+          color="primary"
+          padding="8px 8px"
+          :label="$t('common.actions.add')"
+          icon="mdi-plus"
+          class="q-mt-md"
+          @click="onAddItem"
+        />
+      </template>
+    </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"
-      />
+        :show-columns-select="false"
+        :title="
+          $t('common.terms.list') +
+          ' ' +
+          $t('common.ui.table.of') +
+          ' ' +
+          $t('ui.navigation.state')
+        "
+      >
+        <template #body-cell-actions="{ row }">
+          <q-btn
+            outline
+            style="width: 36px"
+            class="q-ml-auto q-mr-sm"
+            @click.prevent.stop="onRowClick(row)"
+          >
+            <q-icon name="mdi-file-edit-outline" />
+          </q-btn>
+        </template>
+      </DefaultTable>
     </div>
   </div>
 </template>
@@ -64,17 +87,21 @@ const columns = [
     name: "status",
     label: t("common.terms.status"),
     field: (row) =>
-      row.status == "ACTIVE" ? t("common.status.active") : t("common.status.inactive"),
+      row.status == "ACTIVE"
+        ? t("common.status.active")
+        : t("common.status.inactive"),
     align: "left",
     sortable: true,
   },
   {
     name: "actions",
+    label: t("common.terms.actions"),
+    align: "left",
     required: true,
   },
 ];
 
-const onRowClick = ({ row }) => {
+const onRowClick = (row) => {
   if (permission_store.getAccess("config.state", "edit") === false) {
     $q.loading.hide();
     $q.notify({

+ 29 - 25
src/pages/state/components/AddEditStateDialog.vue

@@ -3,52 +3,54 @@
     <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
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
             v-model="form.name"
-            :label="$t('common.terms.name')"
+            v-model:error="validationErrors.name"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.name"
-            :error-message="serverErrors?.name"
+            :label="$t('common.terms.name')"
+            :placeholder="'Nome completo do estado'"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.name = null"
           />
-          <q-input
+          <DefaultInput
             v-model="form.code"
+            v-model:error="validationErrors.code"
+            :rules="[inputRules.required]"
             :label="$t('common.terms.code')"
-            :rules="[inputRules.required, inputRules.max(2)]"
-            :error="!!serverErrors?.code"
-            :error-message="serverErrors?.code"
+            :placeholder="'Ex. SP, PR...'"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.code = null"
           />
           <CountrySelect
             v-model="selectedCountry"
-            :label="$t('ui.navigation.country')"
+            v-model:error="validationErrors.country_id"
+            :placeholder="'Selecione o pais desse estado'"
             :rules="[inputRules.required]"
             :initial-id="form.country_id"
-            :error="!!serverErrors?.country_id"
-            :error-message="serverErrors?.country_id"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.country_id = null"
           />
-          <q-select
+          <DefaultSelect
             v-model="selectedStatus"
-            :label="$t('common.terms.status')"
+            v-model:error="validationErrors.status"
             :options="statusOptions"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.status"
-            :error-message="serverErrors?.status"
+            :label="$t('common.terms.status')"
+            :placeholder="$t('common.terms.status')"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.status = null"
           />
         </q-card-section>
-        <q-card-actions align="center">
-          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+        <q-card-actions>
           <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
           <q-btn
             color="primary"
-            label="OK"
+            :label="
+              state ? $t('common.actions.save') : $t('common.actions.add')
+            "
             :type="'submit'"
             :loading="loading"
             :disable="!hasUpdatedFields"
@@ -68,7 +70,9 @@ import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import CountrySelect from "src/components/regions/CountrySelect.vue";
+import CountrySelect from "src/components/selects/CountrySelect.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([
   // REQUIRED; need to specify some events that your
@@ -104,7 +108,7 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
 
 const {
   loading,
-  serverErrors,
+  validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
   onSuccess: () => onDialogOK(true),

+ 38 - 18
src/pages/users/UsersPage.vue

@@ -1,20 +1,43 @@
 <template>
   <div>
-    <DefaultHeaderPage />
+    <DefaultHeaderPage>
+      <template #after>
+        <q-btn
+          color="primary"
+          padding="8px 8px"
+          :label="$t('common.actions.add')"
+          icon="mdi-plus"
+          class="q-mt-md"
+          @click="onAddItem"
+        />
+      </template>
+    </DefaultHeaderPage>
     <div>
       <DefaultTable
         ref="tableRef"
         :columns="columns"
         :api-call="getUsers"
         :delete-function="deleteUser"
-        :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"
-      />
+        :show-columns-select="false"
+        :title="
+          $t('common.terms.list') +
+          ' ' +
+          $t('common.ui.table.of') +
+          ' ' +
+          $t('user.plural')
+        "
+      >
+        <template #body-cell-actions="{ row }">
+          <q-btn
+            outline
+            style="width: 36px"
+            class="q-ml-auto q-mr-sm"
+            @click.prevent.stop="onRowClick(row)"
+          >
+            <q-icon name="mdi-file-edit-outline" />
+          </q-btn>
+        </template>
+      </DefaultTable>
     </div>
   </div>
 </template>
@@ -40,7 +63,7 @@ const tableRef = useTemplateRef("tableRef");
 
 const columns = [
   {
-    name: "nome",
+    name: "name",
     label: t("common.terms.name"),
     field: "name",
     align: "left",
@@ -56,13 +79,14 @@ const columns = [
   },
   {
     name: "actions",
+    label: t("common.terms.actions"),
+    align: "left",
     required: true,
   },
 ];
 
-const onRowClick = ({ row }) => {
+const onRowClick = (row) => {
   if (permission_store.getAccess("config.user", "view") === false) {
-    $q.loading.hide();
     $q.notify({
       type: "negative",
       message: t("validation.permissions.view"),
@@ -73,8 +97,7 @@ const onRowClick = ({ row }) => {
     component: AddEditUserDialog,
     componentProps: {
       user: row,
-      title: () =>
-        useI18n().t("common.actions.edit") + " " + useI18n().t("user.singular"),
+      title: () => t("common.actions.edit") + " " + t("user.singular"),
     },
   }).onOk(async (success) => {
     if (success) {
@@ -85,7 +108,6 @@ const onRowClick = ({ row }) => {
 
 const onAddItem = () => {
   if (permission_store.getAccess("config.user", "add") === false) {
-    $q.loading.hide();
     $q.notify({
       type: "negative",
       message: t("validation.permissions.add"),
@@ -94,10 +116,8 @@ const onAddItem = () => {
   }
   $q.dialog({
     component: AddEditUserDialog,
-
     componentProps: {
-      title: () =>
-        useI18n().t("common.actions.add") + " " + useI18n().t("user.singular"),
+      title: () => t("common.actions.add") + " " + t("user.singular"),
     },
   }).onOk(async (success) => {
     if (success) {

+ 27 - 24
src/pages/users/components/AddEditUserDialog.vue

@@ -3,64 +3,66 @@
     <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
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
             v-model="form.name"
-            :label="$t('common.terms.name')"
-            :hint="$t('user.profile.name_and_surname')"
+            v-model:error="validationErrors.name"
             :rules="[inputRules.required]"
-            :error="!!serverErrors?.name"
-            :error-message="serverErrors?.name"
+            :label="$t('common.terms.name')"
+            :placeholder="$t('user.profile.name_and_surname')"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.name = null"
           />
           <UserTypeSelect
             v-model="selectedUserType"
+            v-model:error="validationErrors.type"
             :rules="[inputRules.required]"
             :type="form.type"
-            :error="!!serverErrors?.email"
-            :error-message="serverErrors?.email"
+            :label="$t('common.ui.misc.type')"
+            :placeholder="'O tipo delimita as permissões'"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.email = null"
           />
-          <q-input
+          <DefaultInput
             v-model="form.email"
+            v-model:error="validationErrors.email"
+            :rules="[inputRules.email, inputRules.required]"
             label="Email"
-            :rules="[inputRules.email]"
-            :error="!!serverErrors?.type"
-            :error-message="serverErrors?.type"
+            :placeholder="'Ex. email@email.com'"
             class="col-12"
-            @update:model-value="serverErrors.type = null"
           />
           <DefaultPasswordInput
             v-model="form.password"
+            v-model:error="validationErrors.password"
             :rules="
               user
                 ? [inputRules.password]
                 : [inputRules.required, inputRules.password]
             "
-            :error="!!serverErrors?.password"
-            :error-message="serverErrors?.password"
+            :label="$t('common.terms.password')"
+            :placeholder="'Digite uma senha segura'"
             class="col-md-6 col-12"
-            @update:model-value="serverErrors.password = null"
           />
           <DefaultPasswordInput
             v-model="confirmPassword"
-            :label="$t('auth.confirm_password')"
             :rules="
               user
                 ? [inputRules.samePassword(form.password)]
                 : [inputRules.required, inputRules.samePassword(form.password)]
             "
+            :label="$t('auth.confirm_password')"
             class="col-md-6 col-12"
           />
         </q-card-section>
-        <q-card-actions align="center">
-          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+        <q-card-actions>
           <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
           <q-btn
             color="primary"
-            label="OK"
+            :label="user ? $t('common.actions.save') : $t('common.actions.add')"
             :type="'submit'"
             :loading="loading"
             :disable="!hasUpdatedFields"
@@ -81,7 +83,8 @@ import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
-import UserTypeSelect from "./UserTypeSelect.vue";
+import UserTypeSelect from "src/components/selects/UserTypeSelect.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
 defineEmits([
   // REQUIRED; need to specify some events that your
@@ -119,7 +122,7 @@ const confirmPassword = ref("");
 
 const {
   loading,
-  serverErrors,
+  validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
   onSuccess: () => onDialogOK(true),

+ 149 - 0
src/pages/version/SystemVersionsPage.vue

@@ -0,0 +1,149 @@
+<template>
+  <div class="q-pa-md">
+    <DefaultHeaderPage />
+    <q-table
+      :pagination-label="getPaginationLabel"
+      :columns="columns"
+      :rows="versions"
+      :grid="$q.screen.lt.sm"
+      hide-pagination
+      virtual-scroll
+      flat
+      class="softpar-table q-pa-sm q-mt-md"
+    >
+      <template #no-data>
+        <div class="q-mx-auto q-pa-md text-body2">
+          {{ $t("http.errors.no_records_found") }}
+        </div>
+      </template>
+
+      <template #body="props">
+        <q-tr :props="props" class="text-body2">
+          <q-td key="versao" :props="props">
+            {{ props.row.version }}
+          </q-td>
+          <q-td key="atualizacoes" :props="props">
+            <div
+              v-for="change in props.row.changes"
+              :key="change"
+              class="flex q-my-auto q-pt-sm"
+            >
+              <div v-if="change.changes.length > 0">
+                <div
+                  class="text-bold bg-page q-mb-xs"
+                  style="
+                    height: 20px;
+                    width: fit-content;
+                    margin-left: 5px;
+                    border-radius: 5px;
+                    padding-left: 5px;
+                    padding-right: 5px;
+                  "
+                >
+                  {{ change.type }} :
+                </div>
+                <ul>
+                  <li
+                    v-for="changePerType in change.changes"
+                    :key="changePerType"
+                  >
+                    {{ changePerType.description }}
+                  </li>
+                </ul>
+              </div>
+            </div>
+          </q-td>
+          <q-td key="data" :props="props">
+            {{ props.row.date }}
+          </q-td>
+        </q-tr>
+      </template>
+
+      <template #item="{ row }">
+        <div class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3">
+          <q-card bordered flat class="q-pa-sm full-height">
+            <div class="row justify-between items-center q-mb-sm">
+              <div class="text-subtitle1 text-bold">
+                Versão {{ row.version }}
+              </div>
+              <div class="text-caption text-grey">{{ row.date }}</div>
+            </div>
+
+            <q-separator />
+
+            <div class="q-mt-sm text-body2">
+              <div class="text-caption text-grey q-mb-xs">Atualizações:</div>
+
+              <div
+                v-for="change in row.changes"
+                :key="change"
+                class="flex q-my-auto q-pt-sm"
+              >
+                <div v-if="change.changes.length > 0">
+                  <div
+                    class="text-bold bg-page q-mb-xs"
+                    style="
+                      height: 20px;
+                      width: fit-content;
+                      margin-left: 5px;
+                      border-radius: 5px;
+                      padding-left: 5px;
+                      padding-right: 5px;
+                    "
+                  >
+                    {{ change.type }} :
+                  </div>
+                  <ul>
+                    <li
+                      v-for="changePerType in change.changes"
+                      :key="changePerType"
+                    >
+                      {{ changePerType.description }}
+                    </li>
+                  </ul>
+                </div>
+              </div>
+            </div>
+          </q-card>
+        </div>
+      </template>
+    </q-table>
+  </div>
+</template>
+
+<script setup>
+import versions from "./data/versions.js";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const columns = [
+  {
+    name: "versao",
+    label: "Versão do sistema",
+    field: "versao",
+    align: "center",
+    style: "width: 5%; ",
+  },
+  {
+    name: "atualizacoes",
+    label: "Atualizações",
+    field: "atualizacoes",
+    align: "left",
+    style: "width: 75%;",
+  },
+  {
+    name: "data",
+    label: "Data da atualização",
+    field: "data",
+    align: "center",
+    style: "width: 10%",
+  },
+];
+
+const getPaginationLabel = (from, to, last) => {
+  return `${from}-${to} de ${last}`;
+};
+</script>
+
+<style lang="scss">
+@import "src/css/table.scss";
+</style>

+ 63 - 0
src/pages/version/data/versions.js

@@ -0,0 +1,63 @@
+export default [
+  {
+    version: "0.0.1",
+    date: "09/02/2026",
+    changes: [
+      {
+        type: "Funcionalidades",
+        changes: [
+          {
+            description: "Criação do Projeto",
+          },
+          {
+            description:
+              "Adequação do Projeto ao Layout de Cores e Fontes do Cliente",
+          },
+          {
+            description: "Criação do Layout de Login",
+          },
+          {
+            description: "Criação do Layout de Menu Lateral Esquerdo",
+          },
+          {
+            description: "Criação de Permissões do Sistema",
+          },
+          {
+            description: "Tela de Cadastros",
+          },
+          {
+            description: "Criado o Cadastro de Usuários",
+          },
+          {
+            description:
+              "Criado o Cadastro de Clientes",
+          },
+          {
+            description: "Criado o Cadastro de Setores",
+          },
+          {
+            description:
+              "Criado o Cadastro de Cidades, Estados e Países.",
+          },
+          {
+            description: "Criado os Componentes de graficos",
+          },
+          {
+            description: "Criado a Pagina de Dashboard",
+          },
+          {
+            description: "Criado a Pagina de Bem vindo",
+          },
+        ],
+      },
+      {
+        type: "Melhorias",
+        changes: [],
+      },
+      {
+        type: "Correções",
+        changes: [],
+      },
+    ],
+  },
+];

+ 3 - 3
src/router/index.js

@@ -8,7 +8,7 @@ import {
 import routes from "./routes";
 import { Notify } from "quasar";
 import { permissionStore } from "src/stores/permission";
-import { useI18n } from "vue-i18n";
+import { i18n } from "src/boot/i18n";
 import { userStore } from "src/stores/user";
 import { useAuth } from "src/composables/useAuth";
 /*
@@ -48,7 +48,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
     }
     if (userStore().accessToken) {
       if (to.name == "LoginPage") {
-        return next({ name: "DashboardPage" });
+        return next({ name: "HomePage" });
       }
     }
     if (to.meta.requiredPermission) {
@@ -56,7 +56,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
       const permission = getAccess(to.meta.requiredPermission, "view");
       if (!permission) {
         Notify.create({
-          message: useI18n().t("validation.permissions.view"),
+          message: i18n.global.t("validation.permissions.view"),
           type: "negative",
         });
         return next(from);

+ 39 - 4
src/router/routes.js

@@ -14,16 +14,51 @@ const routes = [
     children: [
       {
         path: "",
+        name: "HomePage",
+        component: () => import("src/pages/home/HomePage.vue"),
+        meta: {
+          requireAuth: true,
+        },
+      },
+      {
+        path: "/dashboard",
         name: "DashboardPage",
-        component: () => import("src/pages/dashboard/DashboardPage.vue"),
+        component: () => import("pages/dashboard/DashboardPage.vue"),
         meta: {
-          title: "ui.navigation.dashboard",
+          title: { value: "Dashboard" },
+          description: {
+            value: "page.system-dashboard.description",
+            translate: true,
+          },
           requireAuth: true,
           requiredPermission: "dashboard",
           breadcrumbs: [
             {
               name: "DashboardPage",
-              title: "ui.navigation.dashboard",
+              title: "Dashboard",
+            },
+          ],
+        },
+      },
+      {
+        path: "/versions",
+        name: "SystemVersionsPage",
+        component: () => import("pages/version/SystemVersionsPage.vue"),
+        meta: {
+          title: {
+            value: "ui.navigation.versions",
+            translate: true,
+          },
+          description: {
+            value: "page.versions.description",
+            translate: true,
+          },
+          requireAuth: true,
+          breadcrumbs: [
+            {
+              name: "SystemVersionsPage",
+              title: "ui.navigation.versions",
+              translate: true,
             },
           ],
         },
@@ -38,7 +73,7 @@ const routes = [
       {
         path: "",
         name: "LoginPage",
-        component: () => import("pages/LoginPage.vue"),
+        component: () => import("pages/login/LoginPage.vue"),
         meta: {
           title: "Login",
         },

+ 98 - 0
src/router/routes/config.route.js

@@ -0,0 +1,98 @@
+export default [
+  {
+    path: "/city",
+    name: "CityPage",
+    component: () => import("pages/city/CityPage.vue"),
+    meta: {
+      title: {
+        value: "ui.navigation.city",
+        translate: true,
+      },
+      description: {
+        value: "page.city.description",
+        translate: true,
+      },
+      requireAuth: true,
+      requiredPermission: "config.city",
+      breadcrumbs: [
+        {
+          name: "CityPage",
+          title: "ui.navigation.city",
+          translate: true,
+        },
+      ],
+    },
+  },
+  {
+    path: "/country",
+    name: "CountryPage",
+    component: () => import("pages/country/CountryPage.vue"),
+    meta: {
+      title: {
+        value: "ui.navigation.country",
+        translate: true,
+      },
+      description: {
+        value: "page.country.description",
+        translate: true,
+      },
+      requireAuth: true,
+      requiredPermission: "config.country",
+      breadcrumbs: [
+        {
+          name: "CountryPage",
+          title: "ui.navigation.country",
+          translate: true,
+        },
+      ],
+    },
+  },
+  {
+    path: "/state",
+    name: "StatePage",
+    component: () => import("pages/state/StatePage.vue"),
+    meta: {
+      title: {
+        value: "ui.navigation.state",
+        translate: true,
+      },
+      description: {
+        value: "page.state.description",
+        translate: true,
+      },
+      requireAuth: true,
+      requiredPermission: "config.state",
+      breadcrumbs: [
+        {
+          name: "StatePage",
+          title: "ui.navigation.state",
+          translate: true,
+        },
+      ],
+    },
+  },
+  {
+    path: "/users",
+    name: "UsersPage",
+    component: () => import("pages/users/UsersPage.vue"),
+    meta: {
+      title: {
+        value: "ui.navigation.users",
+        translate: true,
+      },
+      description: {
+        value: "page.users.description",
+        translate: true,
+      },
+      requireAuth: true,
+      requiredPermission: "config.user",
+      breadcrumbs: [
+        {
+          name: "UsersPage",
+          title: "ui.navigation.users",
+          translate: true,
+        },
+      ],
+    },
+  },
+];

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

@@ -1,62 +0,0 @@
-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",
-        },
-      ],
-    },
-  },
-]

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

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

+ 13 - 5
src/stores/navigation.js

@@ -4,11 +4,19 @@ import { permissionStore } from "src/stores/permission";
 
 export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
+    {
+      type: "single",
+      title: "ui.navigation.home",
+      name: "HomePage",
+      icon: "mdi-home-outline",
+      disable: false,
+      permission: true,
+    },
     {
       type: "single",
       title: "ui.navigation.dashboard",
       name: "DashboardPage",
-      icon: "mdi-home-variant-outline",
+      icon: "mdi-poll",
       disable: false,
       permission: false,
       permissionScope: "dashboard",
@@ -16,7 +24,7 @@ export const navigationStore = defineStore("navigation", () => {
     {
       type: "expansive",
       title: "ui.navigation.registration",
-      icon: "mdi-cog-outline",
+      icon: "mdi-plus",
       disable: false,
       permission: false,
       permissionScope: "config",
@@ -66,15 +74,15 @@ export const navigationStore = defineStore("navigation", () => {
     return navigationStructure
       .map((menu) => {
         if (menu.type === "expansive") {
-          if (getAccess(menu.permissionScope, "menu")) {
-            menu.permission = true;
-          }
+          if (getAccess(menu.permissionScope, "menu")) menu.permission = true;
           menu.childrens = menu.childrens.filter((children) => {
+            if (!children?.permissionScope) return true;
             children.permission = getAccess(children.permissionScope, "menu");
             return children.permission;
           });
           return menu.childrens.length > 0 ? menu : null;
         } else {
+          if (!menu?.permissionScope) return menu;
           menu.permission = getAccess(menu.permissionScope, "menu");
           return menu;
         }