فهرست منبع

refactor: ajuste handlers de submit

Gustavo Mantovani 3 روز پیش
والد
کامیت
aee19d8e95
50فایلهای تغییر یافته به همراه6107 افزوده شده و 3894 حذف شده
  1. 42 27
      src/components/defaults/DefaultFilePicker.vue
  2. 14 3
      src/components/defaults/DefaultForm.vue
  3. 57 46
      src/components/defaults/DefaultInput.vue
  4. 161 0
      src/components/defaults/DefaultMediaPicker.vue
  5. 29 24
      src/components/defaults/DefaultSelect.vue
  6. 80 61
      src/components/financial/AddAccountReceivableDialog.vue
  7. 60 44
      src/components/financial/AddCompanyPayableDialog.vue
  8. 58 42
      src/components/financial/AddEditTreasuryAccountDialog.vue
  9. 114 97
      src/components/financial/AddFinancialPlanAccountDialog.vue
  10. 87 62
      src/components/financial/AddTreasuryLaunchDialog.vue
  11. 60 48
      src/components/financial/FinancialAuthDialog.vue
  12. 48 22
      src/components/financial/SettleAccountReceivableDialog.vue
  13. 46 36
      src/components/financial/SettleCompanyPayableDialog.vue
  14. 266 185
      src/components/layout/LeftMenuLayout.vue
  15. 121 72
      src/components/layout/LeftMenuLayoutMobile.vue
  16. 47 55
      src/components/shared/ChangeImageDialog.vue
  17. 9 0
      src/composables/useAuth.js
  18. 79 92
      src/composables/useForm.js
  19. 97 44
      src/composables/useInputRules.js
  20. 2 0
      src/composables/useScroll.js
  21. 25 12
      src/composables/useSubmitHandler.js
  22. 55 28
      src/css/app.scss
  23. 18 11
      src/pages/cadastros/CadastrosPage.vue
  24. 71 41
      src/pages/cadastros/components/AddEditGroupDialog.vue
  25. 275 160
      src/pages/dashboard/components/HolidaysDialog.vue
  26. 92 44
      src/pages/financial/AsaasIntegrationPage.vue
  27. 380 170
      src/pages/kanban/components/AddEditKanbanDialog.vue
  28. 475 362
      src/pages/packages/components/AddEditPackageDialog.vue
  29. 76 43
      src/pages/permissions/components/AddEditPermissionGroupDialog.vue
  30. 75 47
      src/pages/products/ProductsPage.vue
  31. 143 83
      src/pages/products/components/AddEditProductDialog.vue
  32. 132 70
      src/pages/products/components/AddProductDialog.vue
  33. 169 92
      src/pages/products/components/ImportOrderDialog.vue
  34. 69 30
      src/pages/support/components/AddEditReplyDialog.vue
  35. 239 104
      src/pages/support/components/AddEditTicketDialog.vue
  36. 54 24
      src/pages/support/components/AddReplyDialog.vue
  37. 18 15
      src/pages/tbr/TbrPage.vue
  38. 23 18
      src/pages/tbr/components/AddEditInhabitantClassificationDialog.vue
  39. 143 96
      src/pages/tbr/components/AddEditTbrDialog.vue
  40. 60 13
      src/pages/tbr/components/GenerateBillingsDialog.vue
  41. 320 216
      src/pages/tbr/components/GenerateReceivableDialog.vue
  42. 40 10
      src/pages/unit/UnitActionPage.vue
  43. 85 50
      src/pages/unit/components/AddEditHistoryDialog.vue
  44. 296 259
      src/pages/unit/components/AddEditPartnerDialog.vue
  45. 70 46
      src/pages/unit/components/AddMediaDialog.vue
  46. 327 239
      src/pages/unit/components/CreateContractDialog.vue
  47. 295 235
      src/pages/unit/tabs/FinancialTab.vue
  48. 332 275
      src/pages/unit/tabs/UnitDataTab.vue
  49. 43 14
      src/pages/user_types/components/AddUserTypeDialog.vue
  50. 230 127
      src/pages/users/UserActionPage.vue

+ 42 - 27
src/components/defaults/DefaultFilePicker.vue

@@ -11,8 +11,10 @@
       <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"
@@ -21,6 +23,7 @@
       :rules="rules"
       :error="error"
       :error-message="errorMessage"
+      lazy-rules="ondemand"
       class="image-preview-container"
     >
       <div
@@ -43,6 +46,7 @@
             color="grey-6"
             class="absolute-center"
           />
+
           <div
             class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
           >
@@ -65,6 +69,7 @@
 
         <div v-else class="position-relative column full-height flex-center">
           <q-icon name="mdi-file-check" size="48px" color="grey-6" />
+
           <div
             class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
           >
@@ -96,47 +101,47 @@
 
 <script setup>
 import {
-  ref,
-  watch,
-  onUnmounted,
-  useTemplateRef,
-  useAttrs,
   computed,
   onBeforeMount,
+  onUnmounted,
+  ref,
+  useAttrs,
+  useTemplateRef,
+  watch,
 } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
-const { label, rules, accept, type, initialImage } = defineProps({
-  label: {
-    type: String,
-    default: "Select Image",
-  },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
+const { accept, initialImage, label, rules, type } = defineProps({
   accept: {
     type: String,
     default: "image/*",
   },
-  type: {
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
     type: String,
-    default: "image",
+    default: "",
   },
   initialImage: {
     type: String,
     default: null,
   },
-  error: {
-    type: Boolean,
-    default: false,
+  label: {
+    type: String,
+    default: "Select Image",
   },
-  errorMessage: {
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  type: {
     type: String,
-    default: "",
+    default: "image",
   },
 });
 
@@ -162,16 +167,21 @@ const cleanupObjectURL = () => {
 const generateBase64 = (file) => {
   if (!file) {
     base64File.value = null;
+
     return;
   }
+
   const reader = new FileReader();
+
   reader.onload = (e) => {
     base64File.value = e.target.result;
   };
+
   reader.onerror = () => {
     console.error("FileReader failed to read file.");
     base64File.value = null;
   };
+
   reader.readAsDataURL(file);
 };
 
@@ -195,15 +205,18 @@ const handleDragLeave = () => {
 
 const handleDrop = (event) => {
   event.preventDefault();
+
   isDragging.value = false;
 
   const file = event.dataTransfer?.files?.[0];
+
   if (!file) return;
 
   const acceptedMime = accept;
 
   if (acceptedMime.endsWith("/*")) {
     const baseMime = acceptedMime.replace("/*", "");
+
     if (file.type.startsWith(baseMime + "/")) {
       model.value = file;
     }
@@ -235,6 +248,7 @@ watch(model, (newFile) => {
     } else {
       preview.value = "file_selected";
     }
+
     generateBase64(newFile);
   } else {
     preview.value = initialImage || null;
@@ -289,6 +303,13 @@ onUnmounted(cleanupObjectURL);
       margin: auto;
     }
   }
+
+  .q-field__append {
+    position: absolute;
+    top: -5px;
+    right: 10px;
+  }
+
   .q-field__control {
     height: 100%;
     min-height: 200px;
@@ -300,11 +321,5 @@ onUnmounted(cleanupObjectURL);
   .q-field__control-container {
     justify-content: center;
   }
-
-  .q-field__append {
-    position: absolute;
-    top: -5px;
-    right: 10px;
-  }
 }
 </style>

+ 14 - 3
src/components/defaults/DefaultForm.vue

@@ -5,19 +5,30 @@
 </template>
 
 <script setup>
-import { useTemplateRef } from "vue";
 import { QForm } from "quasar";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useTemplateRef } from "vue";
+import { useScroll } from "src/composables/useScroll";
 
 defineOptions({ inheritAttrs: false });
 
 const formRef = useTemplateRef("formRef");
-const { onValidationError } = useFormUpdateTracker({});
+const { scrollToComponent } = useScroll();
+
+const onValidationError = (component) => {
+  if (!component) return;
+
+  component.focus?.();
+
+  scrollToComponent(component);
+};
 
 const validate = (...args) => formRef.value?.validate(...args);
+
 const resetValidation = (...args) => formRef.value?.resetValidation(...args);
+
 const getValidationComponents = (...args) =>
   formRef.value?.getValidationComponents(...args);
+
 const submit = (...args) => formRef.value?.submit(...args);
 
 defineExpose({ validate, resetValidation, getValidationComponents, submit });

+ 57 - 46
src/components/defaults/DefaultInput.vue

@@ -4,8 +4,6 @@
       <q-input
         ref="inputRef"
         v-model="model"
-        v-bind="inputAttrs"
-        hide-bottom-space
         :bg-color="bgColor"
         :class="[inputClass, { 'required-field': required }]"
         :color
@@ -14,8 +12,11 @@
         :input-class="nativeInputClass"
         :label
         :label-color
+        lazy-rules="ondemand"
         :rules
         :outlined
+        hide-bottom-space
+        v-bind="inputAttrs"
         @update:model-value="error = null"
       >
         <template v-for="(_, slotName) in $slots" #[slotName]>
@@ -24,7 +25,7 @@
 
         <template #append>
           <slot name="append">
-            <q-icon v-if="icon" :name="icon" size="sm" color="secondary" />
+            <q-icon v-if="icon" :name="icon" color="secondary" size="sm" />
           </slot>
         </template>
       </q-input>
@@ -46,49 +47,58 @@ defineOptions({
   inheritAttrs: false,
 });
 
-const { bgColor, errorMessage: errorMessageProp, icon, inputClass, label, labelColor, nativeInputClass, rules, outlined } =
-  defineProps({
-    bgColor: {
-      type: String,
-      default: "white",
-    },
-    color: {
-      type: String,
-      default: "secondary",
-    },
-    errorMessage: {
-      type: String,
-      default: null,
-    },
-    icon: {
-      type: String,
-      default: "",
-    },
-    inputClass: {
-      type: String,
-      default: null,
-    },
-    label: {
-      type: String,
-      default: "",
-    },
-    labelColor: {
-      type: String,
-      default: "secondary",
-    },
-    nativeInputClass: {
-      type: String,
-      default: null,
-    },
-    outlined: {
-      type: Boolean,
-      default: true,
-    },
-    rules: {
-      type: Array,
-      default: () => [],
-    },
-  });
+const {
+  bgColor,
+  errorMessage: errorMessageProp,
+  icon,
+  inputClass,
+  label,
+  labelColor,
+  nativeInputClass,
+  rules,
+  outlined,
+} = defineProps({
+  bgColor: {
+    type: String,
+    default: "white",
+  },
+  color: {
+    type: String,
+    default: "secondary",
+  },
+  errorMessage: {
+    type: String,
+    default: null,
+  },
+  icon: {
+    type: String,
+    default: "",
+  },
+  inputClass: {
+    type: String,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: "",
+  },
+  labelColor: {
+    type: String,
+    default: "secondary",
+  },
+  nativeInputClass: {
+    type: String,
+    default: null,
+  },
+  outlined: {
+    type: Boolean,
+    default: true,
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
 
 const attrs = useAttrs();
 
@@ -105,6 +115,7 @@ const required = ref(false);
 const inputAttrs = computed(() => {
   // eslint-disable-next-line
   const { class: _, style: __, ...rest } = attrs;
+
   return rest;
 });
 

+ 161 - 0
src/components/defaults/DefaultMediaPicker.vue

@@ -0,0 +1,161 @@
+<template>
+  <div class="default-media-picker" :class="rootClass">
+    <div v-if="labelOutside && label" class="default-media-picker__label">
+      {{ label }}<span v-if="required" class="text-negative"> *</span>
+    </div>
+
+    <q-file
+      v-model="model"
+      v-bind="$attrs"
+      :accept="accept"
+      :class="{ 'required-field': required }"
+      :dense="dense"
+      :error="!!error"
+      :error-message="errorMessage"
+      :label="labelOutside ? undefined : label"
+      :placeholder="placeholder"
+      :rules="rules"
+      lazy-rules="ondemand"
+      outlined
+      @update:model-value="onFileChange"
+    >
+      <template #prepend>
+        <slot name="prepend">
+          <q-icon name="attach_file" />
+        </slot>
+      </template>
+    </q-file>
+
+    <div
+      v-if="showPreview"
+      class="default-media-picker__preview"
+      :style="{ height: `${previewHeight}px` }"
+    >
+      <img
+        v-if="previewUrl && isImage"
+        alt="Pré-visualização do arquivo"
+        class="default-media-picker__image"
+        :src="previewUrl"
+      />
+
+      <video
+        v-else-if="previewUrl && isVideo"
+        class="default-media-picker__video"
+        controls
+        :src="previewUrl"
+      />
+
+      <q-icon
+        v-else-if="model"
+        color="grey-6"
+        name="mdi-file-check-outline"
+        size="48px"
+      />
+
+      <span v-else class="text-caption text-grey-6">
+        Nenhum arquivo selecionado
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onUnmounted, ref, watch, useAttrs } from "vue";
+
+defineOptions({ inheritAttrs: false });
+
+const {
+  accept,
+  dense,
+  label,
+  labelOutside,
+  placeholder,
+  previewHeight,
+  rules,
+  showPreview,
+} = defineProps({
+  accept: { type: String, default: "image/*,video/*,.pdf" },
+  dense: { type: Boolean, default: false },
+  label: { type: String, default: "Arquivo" },
+  labelOutside: { type: Boolean, default: true },
+  placeholder: { type: String, default: "" },
+  previewHeight: { type: Number, default: 200 },
+  rules: { type: Array, default: () => [] },
+  showPreview: { type: Boolean, default: false },
+});
+
+const attrs = useAttrs();
+
+const model = defineModel({ type: [File, null], default: null });
+
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+  default: null,
+});
+
+const previewUrl = defineModel("previewUrl", { type: String, default: null });
+
+const localObjectUrl = ref(null);
+
+const errorMessage = computed(() =>
+  typeof error.value === "string" ? error.value : "",
+);
+
+const isImage = computed(
+  () => model.value?.type?.startsWith("image/") ?? false,
+);
+
+const isVideo = computed(
+  () => model.value?.type?.startsWith("video/") ?? false,
+);
+
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
+
+const rootClass = computed(() => attrs.class);
+
+const revokeObjectUrl = () => {
+  if (localObjectUrl.value) {
+    URL.revokeObjectURL(localObjectUrl.value);
+
+    localObjectUrl.value = null;
+  }
+}
+
+const onFileChange = (file) => {
+  revokeObjectUrl();
+
+  if (file instanceof File && (isImage.value || isVideo.value)) {
+    localObjectUrl.value = URL.createObjectURL(file);
+    previewUrl.value = localObjectUrl.value;
+  } else {
+    previewUrl.value = null;
+  }
+}
+
+watch(model, (file) => onFileChange(file));
+
+onUnmounted(() => {
+  revokeObjectUrl();
+});
+</script>
+
+<style scoped>
+.default-media-picker__preview {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  margin-top: 8px;
+  overflow: hidden;
+  border: 1px solid #e0e0e0;
+  border-radius: 4px;
+  background: #fafafa;
+}
+
+.default-media-picker__image,
+.default-media-picker__video {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+</style>

+ 29 - 24
src/components/defaults/DefaultSelect.vue

@@ -4,18 +4,19 @@
       <q-select
         ref="selectRef"
         v-model="model"
-        :label
+        hide-bottom-space
+        hide-dropdown-icon
         label-color="secondary"
+        lazy-rules="ondemand"
         v-bind="selectAttrs"
+        :bg-color
+        :class="[inputClass, { 'required-field': required }]"
         :error="!!error"
         :error-message="errorMessage"
-        :rules
+        :label
         :outlined
-        hide-bottom-space
-        :bg-color
-        :class="[inputClass, { 'required-field': required }]"
         :popup-content-class="popupContentClass"
-        hide-dropdown-icon
+        :rules
         @update:model-value="error = null"
       >
         <template v-for="(_, slotName) in $slots" #[slotName]="scope">
@@ -31,54 +32,55 @@
 </template>
 
 <script setup>
-import { ref, onBeforeMount, useAttrs, computed } from "vue";
+import { computed, onBeforeMount, ref, useAttrs } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
 const {
-  label,
+  bgColor,
+  dropdownIcon,
   inputClass,
+  label,
+  outlined,
   popupContentClass,
   rules,
-  bgColor,
-  outlined,
-  dropdownIcon,
 } = defineProps({
-  label: {
+  bgColor: {
     type: String,
-    default: "",
-  },
-  rules: {
-    type: Array,
-    default: () => [],
+    default: "white",
   },
-  inputClass: {
+  dropdownIcon: {
     type: String,
-    default: null,
+    default: "mdi-chevron-down",
   },
-  popupContentClass: {
+  inputClass: {
     type: String,
     default: null,
   },
-  bgColor: {
+  label: {
     type: String,
-    default: "white",
+    default: "",
   },
   outlined: {
     type: Boolean,
     default: true,
   },
-  dropdownIcon: {
+  popupContentClass: {
     type: String,
-    default: "mdi-chevron-down",
+    default: null,
+  },
+  rules: {
+    type: Array,
+    default: () => [],
   },
 });
 
 const attrs = useAttrs();
 
 const model = defineModel({ type: [String, Object, Array, Number, null] });
+
 const error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
@@ -89,6 +91,7 @@ const required = ref(false);
 const selectAttrs = computed(() => {
   // eslint-disable-next-line
   const { class: _, style: __, ...rest } = attrs;
+
   return rest;
 });
 
@@ -96,9 +99,11 @@ const errorMessage = computed(() => {
   if (error.value == null) {
     return void 0;
   }
+
   if (typeof error.value === "boolean") {
     return void 0;
   }
+
   return String(error.value);
 });
 

+ 80 - 61
src/components/financial/AddAccountReceivableDialog.vue

@@ -10,26 +10,28 @@
       />
 
       <DefaultForm ref="formRef" @submit.prevent="onSubmit">
-        <q-scroll-area class="form-dialog-scroll-area">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
           <q-card-section class="row q-col-gutter-md q-pt-none">
             <DefaultInput
               v-model="form.history"
               v-model:error="validationErrors.history"
-              :error-message="validationErrors.history"
-              label="Descrição"
               class="col-12"
-              lazy-rules="ondemand"
-              :rules="[(value) => !!value || 'Informe a descrição']"
+              label="Descrição"
+              :error-message="validationErrors.history"
+              :rules="[inputRules.required, inputRules.max(255)]"
             />
 
             <div class="col-12">
-              <div class="text-caption text-grey-8 q-mb-xs">Tipo da conta</div>
+              <div class="text-caption text-grey-8 q-mb-xs">
+                Tipo da conta <span class="text-negative">*</span>
+              </div>
+
               <q-option-group
                 v-model="form.account_type"
-                :options="accountTypeOptions"
                 color="primary"
                 inline
                 type="radio"
+                :options="accountTypeOptions"
               />
             </div>
 
@@ -37,54 +39,53 @@
               v-if="form.account_type === 'unit'"
               v-model="form.unit"
               v-model:error="validationErrors.unit_id"
-              :error-message="validationErrors.unit_id"
-              label="Unidade"
               class="col-12"
-              :rules="[(value) => !!value || 'Selecione a unidade']"
+              label="Unidade"
+              :error-message="validationErrors.unit_id"
+              :rules="[inputRules.required]"
             />
 
             <DefaultCurrencyInput
               v-model="form.value"
               v-model:error="validationErrors.value"
-              :error-message="validationErrors.value"
-              label="Valor"
               class="col-12 col-sm-6"
-              lazy-rules="ondemand"
-              :rules="[() => Number(form.value) > 0 || 'Informe o valor']"
+              label="Valor"
+              :error-message="validationErrors.value"
+              :rules="[inputRules.requiredNumber, inputRules.minValue(0.01)]"
             />
 
             <DefaultInputDatePicker
               v-model="form.due_date"
               v-model:untreated-date="form.due_date_iso"
               v-model:error="validationErrors.due_date"
-              :error-message="validationErrors.due_date"
-              label="Data de vencimento"
               class="col-12 col-sm-6"
-              lazy-rules="ondemand"
-              :rules="[(value) => !!value || 'Informe o vencimento']"
+              label="Data de vencimento"
+              :error-message="validationErrors.due_date"
+              :rules="[inputRules.required]"
             />
 
             <DefaultSelect
               v-model="form.financial_plan_account_id"
               v-model:error="validationErrors.financial_plan_account_id"
-              :error-message="validationErrors.financial_plan_account_id"
-              label="Plano de contas"
-              :options="planAccountOptions"
               class="col-12"
               clearable
               emit-value
+              label="Plano de contas"
               map-options
+              :error-message="validationErrors.financial_plan_account_id"
+              :options="planAccountOptions"
               :loading="loadingPlanAccounts"
             />
 
             <DefaultInput
               v-model="form.obs"
               v-model:error="validationErrors.obs"
-              :error-message="validationErrors.obs"
-              label="Observação"
-              type="textarea"
               class="col-12"
+              label="Observação"
               rows="3"
+              type="textarea"
+              :error-message="validationErrors.obs"
+              :rules="[inputRules.max(1000)]"
             />
           </q-card-section>
         </q-scroll-area>
@@ -92,17 +93,18 @@
         <q-separator />
         <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
-            label="Cancelar"
             color="negative"
+            label="Cancelar"
             no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
             label="Salvar"
-            type="submit"
             no-caps
+            type="submit"
             unelevated
             :loading="loading"
           />
@@ -113,10 +115,13 @@
 </template>
 
 <script setup>
-import { onMounted, ref, watch } from "vue";
-import { useDialogPluginComponent } from "quasar";
 import { createFranchiseeReceivable } from "src/api/franchisee_account_receive";
 import { getFinancialPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { onMounted, ref, watch } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
@@ -124,13 +129,16 @@ import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
 import UnitSelect from "src/components/selects/UnitSelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
+
 const today = new Date();
+
 const dueDateIso = [
   today.getFullYear(),
   String(today.getMonth() + 1).padStart(2, "0"),
@@ -143,24 +151,22 @@ const accountTypeOptions = [
 ];
 
 const formRef = ref(null);
-const form = ref({
-  account_type: "internal",
-  unit: null,
-  history: "",
-  value: 0,
-  due_date: today.toLocaleDateString("pt-BR"),
-  due_date_iso: dueDateIso,
-  financial_plan_account_id: null,
-  obs: "",
-});
-const planAccountOptions = ref([]);
-const loadingPlanAccounts = ref(false);
-
-watch(
-  () => form.value.account_type,
-  (type) => {
-    if (type === "internal") form.value.unit = null;
+const scrollAreaRef = ref(null);
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    account_type: "internal",
+    unit: null,
+    history: "",
+    value: 0,
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: dueDateIso,
+    financial_plan_account_id: null,
+    obs: "",
   },
+  { containerRef: scrollAreaRef },
 );
 
 const {
@@ -169,13 +175,41 @@ const {
   execute: submitForm,
 } = useSubmitHandler({
   formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
   onSuccess: (response) => onDialogOK(response),
 });
 
+const planAccountOptions = ref([]);
+const loadingPlanAccounts = ref(false);
+
+const onSubmit = async () => {
+  await submitForm(() =>
+    createFranchiseeReceivable({
+      account_type: form.account_type,
+      unit_id: form.account_type === "unit" ? form.unit?.value : null,
+      history: form.history,
+      value: form.value,
+      due_date: form.due_date_iso,
+      financial_plan_account_id: form.financial_plan_account_id || null,
+      obs: form.obs || null,
+    }),
+  );
+};
+
+watch(
+  () => form.account_type,
+  (type) => {
+    if (type === "internal") form.unit = null;
+  },
+);
+
 onMounted(async () => {
   loadingPlanAccounts.value = true;
+
   try {
     const accounts = await getFinancialPlanAccountsForSelect();
+
     planAccountOptions.value = (accounts ?? []).map((account) => ({
       label: `${account.code} — ${account.description}`,
       value: account.id,
@@ -184,19 +218,4 @@ onMounted(async () => {
     loadingPlanAccounts.value = false;
   }
 });
-
-const onSubmit = async () => {
-  await submitForm(() =>
-    createFranchiseeReceivable({
-      account_type: form.value.account_type,
-      unit_id:
-        form.value.account_type === "unit" ? form.value.unit?.value : null,
-      history: form.value.history,
-      value: form.value.value,
-      due_date: form.value.due_date_iso,
-      financial_plan_account_id: form.value.financial_plan_account_id || null,
-      obs: form.value.obs || null,
-    }),
-  );
-};
 </script>

+ 60 - 44
src/components/financial/AddCompanyPayableDialog.vue

@@ -7,78 +7,78 @@
       <DefaultDialogHeader title="Nova conta a pagar" @close="onDialogCancel" />
 
       <DefaultForm ref="formRef" @submit.prevent="onSubmit">
-        <q-scroll-area class="form-dialog-scroll-area">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
           <q-card-section class="row q-col-gutter-md q-pt-none">
             <DefaultInput
               v-model="form.history"
               v-model:error="validationErrors.history"
-              :error-message="validationErrors.history"
-              label="Descrição"
               class="col-12"
-              lazy-rules="ondemand"
-              :rules="[(value) => !!value || 'Informe a descrição']"
+              label="Descrição"
+              :error-message="validationErrors.history"
+              :rules="[inputRules.required, inputRules.max(255)]"
             />
 
             <DefaultCurrencyInput
               v-model="form.value"
               v-model:error="validationErrors.value"
-              :error-message="validationErrors.value"
-              label="Valor"
               class="col-12 col-sm-6"
-              lazy-rules="ondemand"
-              :rules="[() => Number(form.value) > 0 || 'Informe o valor']"
+              label="Valor"
+              :error-message="validationErrors.value"
+              :rules="[inputRules.requiredNumber, inputRules.minValue(0.01)]"
             />
 
             <DefaultInputDatePicker
               v-model="form.due_date"
               v-model:untreated-date="form.due_date_iso"
               v-model:error="validationErrors.due_date"
-              :error-message="validationErrors.due_date"
-              label="Vencimento"
               class="col-12 col-sm-6"
-              lazy-rules="ondemand"
-              :rules="[(value) => !!value || 'Informe o vencimento']"
+              label="Vencimento"
+              :error-message="validationErrors.due_date"
+              :rules="[inputRules.required]"
             />
 
             <DefaultSelect
               v-model="form.financial_plan_account_id"
               v-model:error="validationErrors.financial_plan_account_id"
-              :error-message="validationErrors.financial_plan_account_id"
-              label="Plano de contas (opcional)"
-              :options="planAccountOptions"
               class="col-12"
               clearable
               emit-value
+              label="Plano de contas (opcional)"
               map-options
+              :error-message="validationErrors.financial_plan_account_id"
               :loading="loadingPlanAccounts"
+              :options="planAccountOptions"
             />
 
             <DefaultInput
               v-model="form.obs"
               v-model:error="validationErrors.obs"
-              :error-message="validationErrors.obs"
-              label="Observação (opcional)"
-              type="textarea"
               class="col-12"
+              label="Observação (opcional)"
               rows="3"
+              type="textarea"
+              :error-message="validationErrors.obs"
+              :rules="[inputRules.max(1000)]"
             />
           </q-card-section>
         </q-scroll-area>
 
         <q-separator />
+
         <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
-            label="Cancelar"
             color="negative"
+            label="Cancelar"
             no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
             label="Salvar"
-            type="submit"
             no-caps
+            type="submit"
             unelevated
             :loading="loading"
           />
@@ -89,10 +89,13 @@
 </template>
 
 <script setup>
-import { onMounted, ref } from "vue";
-import { useDialogPluginComponent } from "quasar";
 import { createCompanyPayable } from "src/api/company_payable";
 import { getFinancialPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { onMounted, ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
@@ -107,6 +110,7 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
 const today = new Date();
+
 const dueDateIso = [
   today.getFullYear(),
   String(today.getMonth() + 1).padStart(2, "0"),
@@ -114,14 +118,22 @@ const dueDateIso = [
 ].join("-");
 
 const formRef = ref(null);
-const form = ref({
-  history: "",
-  value: 0,
-  due_date: today.toLocaleDateString("pt-BR"),
-  due_date_iso: dueDateIso,
-  financial_plan_account_id: null,
-  obs: "",
-});
+const scrollAreaRef = ref(null);
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    history: "",
+    value: 0,
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: dueDateIso,
+    financial_plan_account_id: null,
+    obs: "",
+  },
+  { containerRef: scrollAreaRef },
+);
 
 const planAccountOptions = ref([]);
 const loadingPlanAccounts = ref(false);
@@ -132,13 +144,29 @@ const {
   execute: submitForm,
 } = useSubmitHandler({
   formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
   onSuccess: (response) => onDialogOK(response),
 });
 
+const onSubmit = async () => {
+  await submitForm(() =>
+    createCompanyPayable({
+      history: form.history,
+      value: form.value,
+      due_date: form.due_date_iso,
+      financial_plan_account_id: form.financial_plan_account_id || null,
+      obs: form.obs || null,
+    }),
+  );
+};
+
 onMounted(async () => {
   loadingPlanAccounts.value = true;
+
   try {
     const planAccounts = await getFinancialPlanAccountsForSelect();
+
     planAccountOptions.value = (planAccounts ?? []).map((account) => ({
       label: `${account.code} — ${account.description}`,
       value: account.id,
@@ -147,16 +175,4 @@ onMounted(async () => {
     loadingPlanAccounts.value = false;
   }
 });
-
-const onSubmit = async () => {
-  await submitForm(() =>
-    createCompanyPayable({
-      history: form.value.history,
-      value: form.value.value,
-      due_date: form.value.due_date_iso,
-      financial_plan_account_id: form.value.financial_plan_account_id || null,
-      obs: form.value.obs || null,
-    }),
-  );
-};
 </script>

+ 58 - 42
src/components/financial/AddEditTreasuryAccountDialog.vue

@@ -5,57 +5,57 @@
       style="width: 600px; max-width: 90vw"
     >
       <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
           <q-card-section class="row q-col-gutter-sm q-pt-none">
             <DefaultInput
               v-model="form.name"
               v-model:error="validationErrors.name"
-              :error-message="validationErrors.name"
-              :rules="[inputRules.required]"
+              class="col-12"
               label="Nome da conta"
               placeholder="Ex. Conta Principal"
-              class="col-12"
+              :error-message="validationErrors.name"
+              :rules="[inputRules.required]"
             />
 
             <DefaultInput
               v-model="form.bank_name"
               v-model:error="validationErrors.bank_name"
-              :error-message="validationErrors.bank_name"
-              :rules="[inputRules.required]"
+              class="col-12 col-md-6"
               label="Banco"
               placeholder="Ex. Banco do Brasil"
-              class="col-12 col-md-6"
+              :error-message="validationErrors.bank_name"
             />
 
             <DefaultSelect
               v-model="form.bank_type_account"
               v-model:error="validationErrors.bank_type_account"
-              :error-message="validationErrors.bank_type_account"
-              :options="accountTypeOptions"
+              class="col-12 col-md-6"
               emit-value
-              map-options
               label="Tipo de conta"
+              map-options
               placeholder="Selecione"
-              class="col-12 col-md-6"
+              :error-message="validationErrors.bank_type_account"
+              :options="accountTypeOptions"
             />
 
             <DefaultInput
               v-model="form.bank_agency"
               v-model:error="validationErrors.bank_agency"
-              :error-message="validationErrors.bank_agency"
+              class="col-12 col-md-6"
               label="Agência"
               placeholder="Ex. 1234"
-              class="col-12 col-md-6"
+              :error-message="validationErrors.bank_agency"
             />
 
             <DefaultInput
               v-model="form.bank_account"
               v-model:error="validationErrors.bank_account"
-              :error-message="validationErrors.bank_account"
+              class="col-12 col-md-6"
               label="Conta"
               placeholder="Ex. 56789-0"
-              class="col-12 col-md-6"
+              :error-message="validationErrors.bank_account"
             />
           </q-card-section>
         </q-scroll-area>
@@ -63,15 +63,16 @@
         <q-card-actions>
           <q-space />
           <q-btn
-            outline
             color="negative"
             label="Cancelar"
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
-            :label="isEdit ? 'Salvar' : 'Cadastrar'"
             type="submit"
+            :label="isEdit ? 'Salvar' : 'Cadastrar'"
             :loading="loading"
           />
         </q-card-actions>
@@ -81,15 +82,19 @@
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
-import { useDialogPluginComponent } from "quasar";
-import { useInputRules } from "src/composables/useInputRules";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { computed, ref } from "vue";
+
 import {
   createTreasuryAccount,
   updateTreasuryAccount,
 } from "src/api/treasury_account";
 
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+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";
@@ -108,22 +113,21 @@ const { inputRules } = useInputRules();
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
-const isEdit = computed(() => !!account);
-const title = computed(() => (isEdit.value ? "Editar Banco" : "Novo Banco"));
-
-const accountTypeOptions = [
-  { label: "Conta Corrente", value: "corrente" },
-  { label: "Conta Poupança", value: "poupanca" },
-];
+const { scrollToComponent } = useScroll();
 
-const form = ref({
-  name: account?.name ?? "",
-  bank_name: account?.bank_name ?? "",
-  bank_agency: account?.bank_agency ?? "",
-  bank_account: account?.bank_account ?? "",
-  bank_type_account: account?.bank_type_account ?? null,
-});
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { form, getUpdatedFields } = useForm(
+  {
+    name: account?.name ?? "",
+    bank_name: account?.bank_name ?? "",
+    bank_agency: account?.bank_agency ?? "",
+    bank_account: account?.bank_account ?? "",
+    bank_type_account: account?.bank_type_account ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
 
 const {
   loading,
@@ -132,20 +136,32 @@ const {
 } = useSubmitHandler({
   onSuccess: (response) => onDialogOK(response),
   formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
 });
 
+const isEdit = computed(() => !!account);
+const title = computed(() => (isEdit.value ? "Editar Banco" : "Novo Banco"));
+
+const accountTypeOptions = [
+  { label: "Conta Corrente", value: "corrente" },
+  { label: "Conta Poupança", value: "poupanca" },
+];
+
 const onOKClick = async () => {
   const payload = {
-    name: form.value.name,
+    name: form.name,
     treasury_type: "bank",
-    bank_name: form.value.bank_name,
-    bank_agency: form.value.bank_agency || null,
-    bank_account: form.value.bank_account || null,
-    bank_type_account: form.value.bank_type_account || null,
+    bank_name: form.bank_name || null,
+    bank_agency: form.bank_agency || null,
+    bank_account: form.bank_account || null,
+    bank_type_account: form.bank_type_account || null,
   };
 
   if (isEdit.value) {
-    await submitForm(() => updateTreasuryAccount(account.id, payload));
+    await submitForm(() =>
+      updateTreasuryAccount(account.id, getUpdatedFields.value),
+    );
   } else {
     await submitForm(() => createTreasuryAccount(payload));
   }

+ 114 - 97
src/components/financial/AddFinancialPlanAccountDialog.vue

@@ -5,89 +5,92 @@
       style="width: 520px; max-width: 90vw"
     >
       <DefaultDialogHeader title="Nova Conta" @close="onDialogCancel" />
-      <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
-            <!-- Tipo: conta pai (raiz) ou conta filha (vinculada a um pai) -->
-            <q-option-group
-              v-model="accountKind"
-              :options="kindOptions"
-              color="primary"
-              inline
-              class="q-mb-sm"
-            />
 
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="form.code"
-                v-model:error="validationErrors.code"
-                :error-message="validationErrors.code"
-                :rules="[inputRules.required]"
-                label="Código"
-                :placeholder="accountKind === 'child' ? '3.1.1' : '3'"
-                class="col-12 col-md-4"
+      <DefaultForm ref="formRef" @submit="onOKClick">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12">
+              <q-option-group
+                v-model="accountKind"
+                color="primary"
+                inline
+                :options="kindOptions"
               />
+            </div>
 
-              <DefaultInput
-                v-model="form.description"
-                v-model:error="validationErrors.description"
-                :error-message="validationErrors.description"
-                :rules="[inputRules.required]"
-                label="Nome da Conta"
-                placeholder="Ex. Contas a Pagar"
-                class="col-12 col-md-8"
-              />
+            <DefaultInput
+              v-model="form.code"
+              v-model:error="validationErrors.code"
+              class="col-12 col-md-4"
+              label="Código"
+              :error-message="validationErrors.code"
+              :placeholder="accountKind === 'child' ? '3.1.1' : '3'"
+              :rules="[inputRules.required]"
+            />
 
-              <!-- Conta pai: define o tipo -->
-              <DefaultInput
-                v-if="accountKind === 'parent'"
-                v-model="form.chart_type"
-                v-model:error="validationErrors.chart_type"
-                :error-message="validationErrors.chart_type"
-                :rules="[inputRules.required]"
-                label="Tipo de Conta"
-                placeholder="Ex. Ativo, Passivo, Receitas, Despesas"
-                class="col-12"
-              />
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              class="col-12 col-md-8"
+              label="Nome da Conta"
+              placeholder="Ex. Contas a Pagar"
+              :error-message="validationErrors.description"
+              :rules="[inputRules.required]"
+            />
 
-              <!-- Conta filha: escolhe o pai (herda o tipo) -->
-              <DefaultSelect
-                v-else
-                v-model="form.parent_id"
-                v-model:error="validationErrors.parent_id"
-                :error-message="validationErrors.parent_id"
-                :options="parentOptions"
-                option-value="id"
-                option-label="label"
-                emit-value
-                map-options
-                :loading="loadingParents"
-                :rules="[inputRules.required]"
-                label="Conta Pai"
-                class="col-12"
-              />
-            </div>
+            <DefaultInput
+              v-if="accountKind === 'parent'"
+              v-model="form.chart_type"
+              v-model:error="validationErrors.chart_type"
+              class="col-12"
+              label="Tipo de Conta"
+              placeholder="Ex. Ativo, Passivo, Receitas, Despesas"
+              :error-message="validationErrors.chart_type"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-else
+              v-model="form.parent_id"
+              v-model:error="validationErrors.parent_id"
+              class="col-12"
+              emit-value
+              label="Conta Pai"
+              map-options
+              option-label="label"
+              option-value="id"
+              :error-message="validationErrors.parent_id"
+              :loading="loadingParents"
+              :options="parentOptions"
+              :rules="[inputRules.required]"
+            />
 
             <div
               v-if="accountKind === 'child' && selectedParent"
-              class="text-caption text-grey-7 q-mt-xs"
+              class="col-12 text-caption text-grey-7"
             >
               Tipo herdado do pai: <b>{{ selectedParent.chart_type }}</b>
             </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right">
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
             color="negative"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
             label="Salvar"
+            no-caps
             type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -97,90 +100,104 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
-import { useDialogPluginComponent } from "quasar";
-import { useInputRules } from "src/composables/useInputRules";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { computed, onMounted, ref } from "vue";
+
 import {
   createFinancialPlanAccount,
   getFinancialPlanAccounts,
 } from "src/api/financial_plan_account";
 
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+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";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { inputRules } = useInputRules();
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
+const { scrollToComponent } = useScroll();
 
-const accountKind = ref("parent");
-const kindOptions = [
-  { label: "Conta Pai", value: "parent" },
-  { label: "Conta Filho", value: "child" },
-];
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { form } = useForm(
+  {
+    chart_type: "",
+    code: "",
+    description: "",
+    parent_id: null,
+  },
+  { containerRef: scrollAreaRef },
+);
 
-const form = ref({
-  code: "",
-  description: "",
-  chart_type: "",
-  parent_id: null,
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
+  scrollFn: scrollToComponent,
 });
 
+const accountKind = ref("parent");
 const accounts = ref([]);
 const loadingParents = ref(false);
 
 const parentOptions = computed(() =>
-  accounts.value.map((a) => ({
-    id: a.id,
-    label: `${a.code} — ${a.description}`,
-    chart_type: a.chart_type,
+  accounts.value.map((account) => ({
+    chart_type: account.chart_type,
+    id: account.id,
+    label: `${account.code} — ${account.description}`,
   })),
 );
 
 const selectedParent = computed(() =>
-  parentOptions.value.find((o) => o.id === form.value.parent_id),
+  parentOptions.value.find((option) => option.id === form.parent_id),
 );
 
+const kindOptions = [
+  { label: "Conta Pai", value: "parent" },
+  { label: "Conta Filho", value: "child" },
+];
+
 const loadAccounts = async () => {
   loadingParents.value = true;
+
   try {
     accounts.value = await getFinancialPlanAccounts();
-  } catch (e) {
-    console.error("Erro ao carregar contas para vínculo:", e);
+  } catch (error) {
+    console.error("Erro ao carregar contas para vínculo:", error);
   } finally {
     loadingParents.value = false;
   }
 };
 
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
-  onSuccess: (response) => onDialogOK(response),
-  formRef,
-});
-
 const onOKClick = async () => {
   const payload = {
-    code: form.value.code,
-    description: form.value.description,
+    code: form.code,
+    description: form.description,
   };
 
   if (accountKind.value === "child") {
-    payload.parent_id = form.value.parent_id;
+    payload.parent_id = form.parent_id;
   } else {
-    payload.chart_type = form.value.chart_type;
+    payload.chart_type = form.chart_type;
   }
 
   await submitForm(() => createFinancialPlanAccount(payload));
 };
 
 onMounted(loadAccounts);
-</script>
+</script>

+ 87 - 62
src/components/financial/AddTreasuryLaunchDialog.vue

@@ -5,69 +5,76 @@
       style="width: 520px; max-width: 90vw"
     >
       <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="row q-col-gutter-sm q-pt-none">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
             <DefaultSelect
               v-model="form.transaction_type"
               v-model:error="validationErrors.transaction_type"
-              :error-message="validationErrors.transaction_type"
-              :options="typeOptions"
+              class="col-12"
               emit-value
-              map-options
-              :rules="[inputRules.required]"
               label="Tipo"
+              map-options
               placeholder="Entrada ou Saída"
-              class="col-12"
+              :error-message="validationErrors.transaction_type"
+              :options="typeOptions"
+              :rules="[inputRules.required]"
             />
 
             <DefaultInput
               v-model="form.description"
               v-model:error="validationErrors.description"
-              :error-message="validationErrors.description"
-              :rules="[inputRules.required]"
+              class="col-12"
               label="Descrição"
               placeholder="Descreva a movimentação"
-              class="col-12"
+              :error-message="validationErrors.description"
+              :rules="[inputRules.required]"
             />
 
             <DefaultSelect
               v-model="form.financial_plan_account_id"
               v-model:error="validationErrors.financial_plan_account_id"
-              :error-message="validationErrors.financial_plan_account_id"
-              :options="planAccountOptions"
-              :loading="loadingPlanAccounts"
+              class="col-12"
               emit-value
-              map-options
-              use-input
               input-debounce="0"
-              :rules="[inputRules.required]"
               label="Plano de Contas"
-              class="col-12"
+              map-options
+              use-input
+              :error-message="validationErrors.financial_plan_account_id"
+              :loading="loadingPlanAccounts"
+              :options="planAccountOptions"
               @filter="filterPlanAccounts"
             />
 
             <DefaultCurrencyInput
               v-model="form.amount"
               v-model:error="validationErrors.amount"
-              :error-message="validationErrors.amount"
-              label="Valor"
               class="col-12"
+              label="Valor"
+              :error-message="validationErrors.amount"
+              :rules="[inputRules.requiredNumber, inputRules.minValue(0.01)]"
             />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right">
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
             color="negative"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
             label="Salvar"
+            no-caps
             type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -77,40 +84,72 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import { useDialogPluginComponent } from "quasar";
-import { useInputRules } from "src/composables/useInputRules";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import {
   createTreasuryLaunch,
   updateTreasuryLaunch,
 } from "src/api/treasury_launch";
+
 import { getFinancialPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { onMounted, ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { account, launch } = defineProps({
   account: {
-    type: Object,
     required: true,
+    type: Object,
   },
   launch: {
-    type: Object,
     default: null,
+    type: Object,
   },
 });
 
 const { inputRules } = useInputRules();
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm(
+  {
+    amount: launch?.amount ?? 0,
+    description: launch?.description ?? "",
+    financial_plan_account_id: launch?.financial_plan_account_id ?? null,
+    transaction_type: launch?.transaction_type ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
+  scrollFn: scrollToComponent,
+});
+
+const allPlanAccounts = ref([]);
+const loadingPlanAccounts = ref(false);
+const planAccountOptions = ref([]);
+
 const isEdit = !!launch;
 const title = `${isEdit ? "Editar" : "Nova"} movimentação — ${account.name}`;
 
@@ -119,64 +158,50 @@ const typeOptions = [
   { label: "Saída", value: "saida" },
 ];
 
-const form = ref({
-  transaction_type: launch?.transaction_type ?? null,
-  description: launch?.description ?? "",
-  financial_plan_account_id: launch?.financial_plan_account_id ?? null,
-  amount: launch?.amount ?? 0,
-});
-
-const allPlanAccounts = ref([]);
-const planAccountOptions = ref([]);
-const loadingPlanAccounts = ref(false);
-
 const filterPlanAccounts = (needle, update) => {
   update(() => {
     const term = needle.toLowerCase();
+
     planAccountOptions.value = term
-      ? allPlanAccounts.value.filter((o) =>
-          o.label.toLowerCase().includes(term),
+      ? allPlanAccounts.value.filter((option) =>
+          option.label.toLowerCase().includes(term),
         )
       : allPlanAccounts.value;
   });
 };
 
-onMounted(async () => {
+const loadPlanAccounts = async () => {
   loadingPlanAccounts.value = true;
+
   try {
     const accounts = await getFinancialPlanAccountsForSelect();
-    allPlanAccounts.value = accounts.map((a) => ({
-      label: `${a.code} - ${a.description}`,
-      value: a.id,
+
+    allPlanAccounts.value = accounts.map((account) => ({
+      label: `${account.code} - ${account.description}`,
+      value: account.id,
     }));
+
     planAccountOptions.value = allPlanAccounts.value;
   } finally {
     loadingPlanAccounts.value = false;
   }
-});
-
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
-  onSuccess: (response) => onDialogOK(response),
-  formRef,
-});
+};
 
 const onOKClick = async () => {
   const payload = {
     account_id: account.id,
-    transaction_type: form.value.transaction_type,
-    description: form.value.description,
-    financial_plan_account_id: form.value.financial_plan_account_id,
-    amount: form.value.amount,
+    amount: form.amount,
+    description: form.description,
+    financial_plan_account_id: form.financial_plan_account_id,
+    transaction_type: form.transaction_type,
   };
 
   await submitForm(() =>
     isEdit
-      ? updateTreasuryLaunch(launch.id, payload)
+      ? updateTreasuryLaunch(launch.id, getUpdatedFields.value)
       : createTreasuryLaunch(payload),
   );
 };
-</script>
+
+onMounted(loadPlanAccounts);
+</script>

+ 60 - 48
src/components/financial/FinancialAuthDialog.vue

@@ -1,87 +1,99 @@
 <template>
   <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="min-width: 400px">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="min-width: 400px"
+    >
       <DefaultDialogHeader title="Acesso ao Financeiro" @close="onCancel" />
 
-      <q-scroll-area class="form-dialog-scroll-area">
-        <q-card-section class="q-pt-none q-pb-sm">
-          <div class="text-caption text-grey-6 q-mb-sm">
-            Confirme sua senha para acessar o módulo Financeiro.
-          </div>
-          <DefaultForm @submit.prevent="onConfirm">
-            <q-input
+      <DefaultForm @submit.prevent="onConfirm">
+        <q-scroll-area class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-caption text-grey-6">
+              Confirme sua senha para acessar o módulo Financeiro.
+            </div>
+
+            <DefaultPasswordInput
               ref="passwordInput"
               v-model="password"
-              type="password"
-              outlined
-              dense
               autofocus
+              class="col-12"
+              dense
               label="Senha"
+              outlined
               :error="hasError"
               :error-message="errorMessage"
               @update:model-value="hasError = false"
-            >
-              <template #prepend>
-                <q-icon name="mdi-lock-outline" />
-              </template>
-            </q-input>
-          </DefaultForm>
-        </q-card-section>
-      </q-scroll-area>
-
-      <q-card-actions align="right">
-        <q-btn
-          outline
-          color="primary"
-          label="Cancelar"
-          :disable="loading"
-          @click="onCancel"
-        />
-        <q-btn
-          color="primary"
-          label="Confirmar"
-          :loading="loading"
-          :disable="!password"
-          @click="onConfirm"
-        />
-      </q-card-actions>
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            :disable="loading"
+            @click="onCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Confirmar"
+            no-caps
+            type="submit"
+            unelevated
+            :disable="!password"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
+import { confirmPassword } from "src/api/auth";
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import { confirmPassword } from "src/api/auth";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const password = ref("");
-const loading = ref(false);
-const hasError = ref(false);
 const errorMessage = ref("");
+const hasError = ref(false);
+const loading = ref(false);
+const password = ref("");
+
+const onCancel = () => {
+  onDialogCancel();
+};
 
-async function onConfirm() {
+const onConfirm = async () => {
   if (!password.value || loading.value) return;
+
   loading.value = true;
   hasError.value = false;
+
   try {
     await confirmPassword(password.value);
+
     onDialogOK();
   } catch (error) {
     hasError.value = true;
+
     errorMessage.value =
       error?.response?.data?.message || "A senha fornecida está incorreta.";
   } finally {
     loading.value = false;
   }
-}
-
-function onCancel() {
-  onDialogCancel();
-}
-</script>
+};
+</script>

+ 48 - 22
src/components/financial/SettleAccountReceivableDialog.vue

@@ -5,35 +5,46 @@
       style="width: 420px; max-width: 90vw"
     >
       <DefaultDialogHeader title="Marcar como pago" @close="onDialogCancel" />
+
       <DefaultForm ref="formRef" @submit.prevent="onSubmit">
         <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
-            <div class="text-body2 q-mb-md">{{ receivable.history }}</div>
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-body2">
+              {{ receivable.history }}
+            </div>
+
             <DefaultInputDatePicker
-              v-model="paymentDate"
-              v-model:untreated-date="paymentDateIso"
+              v-model="form.payment_date"
+              v-model:untreated-date="form.payment_date_iso"
               v-model:error="validationErrors.payment_date"
-              :error-message="validationErrors.payment_date"
+              class="col-12"
               label="Data do pagamento"
               lazy-rules="ondemand"
-              :rules="[(value) => !!value || 'Informe a data do pagamento']"
+              :error-message="validationErrors.payment_date"
+              :rules="[
+                (value) => !!value || 'Informe a data do pagamento',
+              ]"
             />
           </q-card-section>
         </q-scroll-area>
+
         <q-separator />
+
         <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
             color="negative"
             label="Cancelar"
             no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
             label="Confirmar"
-            type="submit"
             no-caps
+            type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -44,28 +55,42 @@
 
 <script setup>
 import { ref } from "vue";
-import { useDialogPluginComponent } from "quasar";
 import { settleFranchiseeReceivable } from "src/api/franchisee_account_receive";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
+
 const { receivable } = defineProps({
-  receivable: { type: Object, required: true },
+  receivable: {
+    required: true,
+    type: Object,
+  },
 });
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
-const today = new Date();
-const paymentDate = ref(today.toLocaleDateString("pt-BR"));
-const paymentDateIso = ref(
-  [
-    today.getFullYear(),
-    String(today.getMonth() + 1).padStart(2, "0"),
-    String(today.getDate()).padStart(2, "0"),
-  ].join("-"),
-);
+
 const formRef = ref(null);
+
+const today = new Date();
+
+const paymentDateIso = [
+  today.getFullYear(),
+  String(today.getMonth() + 1).padStart(2, "0"),
+  String(today.getDate()).padStart(2, "0"),
+].join("-");
+
+const { form } = useForm({
+  payment_date: today.toLocaleDateString("pt-BR"),
+  payment_date_iso: paymentDateIso,
+});
+
 const {
   loading,
   validationErrors,
@@ -74,10 +99,11 @@ const {
   formRef,
   onSuccess: (response) => onDialogOK(response),
 });
+
 const onSubmit = () =>
   submitForm(() =>
     settleFranchiseeReceivable(receivable.id, {
-      payment_date: paymentDateIso.value,
+      payment_date: form.payment_date_iso,
     }),
   );
-</script>
+</script>

+ 46 - 36
src/components/financial/SettleCompanyPayableDialog.vue

@@ -17,51 +17,57 @@
               v-model="form.payment_date"
               v-model:untreated-date="form.payment_date_iso"
               v-model:error="validationErrors.payment_date"
-              :error-message="validationErrors.payment_date"
-              label="Data do pagamento"
               class="col-12"
+              label="Data do pagamento"
               lazy-rules="ondemand"
-              :rules="[(value) => !!value || 'Informe a data do pagamento']"
+              :error-message="validationErrors.payment_date"
+              :rules="[
+                (value) => !!value || 'Informe a data do pagamento',
+              ]"
             />
 
             <DefaultCurrencyInput
               v-model="form.discount"
               v-model:error="validationErrors.discount"
-              :error-message="validationErrors.discount"
-              label="Desconto"
               class="col-12 col-sm-6"
+              label="Desconto"
+              :error-message="validationErrors.discount"
             />
+
             <DefaultCurrencyInput
               v-model="form.fine"
               v-model:error="validationErrors.fine"
-              :error-message="validationErrors.fine"
-              label="Multa/Juros"
               class="col-12 col-sm-6"
+              label="Multa/Juros"
+              :error-message="validationErrors.fine"
             />
 
             <div class="col-12 text-caption text-grey-7">
               Valor da conta: {{ formatCurrency(payable.value) }} ·
-              <span class="text-weight-medium"
-                >Pago: {{ formatCurrency(netValue) }}</span
-              >
+
+              <span class="text-weight-medium">
+                Pago: {{ formatCurrency(netValue) }}
+              </span>
             </div>
           </q-card-section>
         </q-scroll-area>
 
         <q-separator />
+
         <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
-            label="Cancelar"
             color="negative"
+            label="Cancelar"
             no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
             color="primary"
             label="Confirmar baixa"
-            type="submit"
             no-caps
+            type="submit"
             unelevated
             :loading="loading"
           />
@@ -73,8 +79,9 @@
 
 <script setup>
 import { computed, ref } from "vue";
-import { useDialogPluginComponent } from "quasar";
 import { settleCompanyPayable } from "src/api/company_payable";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
@@ -85,58 +92,61 @@ defineEmits([...useDialogPluginComponent.emits]);
 
 const { payable } = defineProps({
   payable: {
-    type: Object,
     required: true,
+    type: Object,
   },
 });
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
+const formRef = ref(null);
+
 const today = new Date();
+
 const paymentDateIso = [
   today.getFullYear(),
   String(today.getMonth() + 1).padStart(2, "0"),
   String(today.getDate()).padStart(2, "0"),
 ].join("-");
 
-const formRef = ref(null);
-const form = ref({
-  payment_date: today.toLocaleDateString("pt-BR"),
-  payment_date_iso: paymentDateIso,
+const initialForm = {
   discount: Number(payable.discount ?? 0),
   fine: Number(payable.fine ?? 0),
+  payment_date: today.toLocaleDateString("pt-BR"),
+  payment_date_iso: paymentDateIso,
+};
+
+const { form, getUpdatedFields } = useForm(initialForm);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
 });
 
 const netValue = computed(
   () =>
     Number(payable.value ?? 0) -
-    Number(form.value.discount ?? 0) +
-    Number(form.value.fine ?? 0),
+    Number(form.discount ?? 0) +
+    Number(form.fine ?? 0),
 );
 
 const formatCurrency = (value) =>
   new Intl.NumberFormat("pt-BR", {
-    style: "currency",
     currency: "BRL",
+    style: "currency",
   }).format(Number(value ?? 0));
 
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
-  formRef,
-  onSuccess: (response) => onDialogOK(response),
-});
-
 const onSubmit = async () => {
   await submitForm(() =>
     settleCompanyPayable(payable.id, {
-      payment_date: form.value.payment_date_iso,
-      discount: form.value.discount,
-      fine: form.value.fine,
+      ...getUpdatedFields.value,
+      payment_date: form.payment_date_iso,
     }),
   );
 };
-</script>
+</script>

+ 266 - 185
src/components/layout/LeftMenuLayout.vue

@@ -1,19 +1,22 @@
 <template>
   <q-drawer
-    v-bind="$attrs"
-    :model-value="true"
-    show-if-above
-    no-swipe-close
-    no-swipe-open
-    :width="214"
-    :mini-width="60"
+    :behavior="'desktop'"
     :breakpoint="500"
     :mini="miniState"
-    :behavior="'desktop'"
+    :mini-width="60"
+    :model-value="true"
+    :width="214"
     class="detached-container"
+    no-swipe-close
+    no-swipe-open
+    show-if-above
+    v-bind="$attrs"
   >
     <div class="column full-height no-wrap q-drawer-container">
-      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
+      <div
+        class="overflow-hidden"
+        style="border-radius: 8px 8px 0px 0px"
+      >
         <div
           class="flex flex-center full-width q-pa-sm bg-terciary"
           style="height: 60px"
@@ -23,11 +26,16 @@
             :src="Logo"
             style="width: 138px; height: 39px"
           />
-          <q-img v-else :src="MiniLogo" style="width: 59px; height: 50px" />
+
+          <q-img
+            v-else
+            :src="MiniLogo"
+            style="width: 59px; height: 50px"
+          />
         </div>
       </div>
 
-      <div class="column full-height no-wrap">
+      <div class="column no-wrap menu-content">
         <div
           v-if="!$q.screen.lt.md"
           class="toggle-button-wrapper absolute"
@@ -35,190 +43,229 @@
         >
           <div @click="miniState = !miniState">
             <q-icon
+              color="secondary"
               size="sm"
               :name="
                 miniState
                   ? 'mdi-page-layout-sidebar-right'
                   : 'mdi-page-layout-sidebar-left'
               "
-              color="secondary"
             />
+
             <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-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-section avatar>
-                  <q-icon
-                    :name="item.icon"
-                    style="font-size: 20px"
-                    color="dark"
-                  />
-                </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-scroll-area class="menu-scroll-area">
+          <q-list class="column no-wrap">
+            <template
+              v-for="(item, index) in navigation_store.navigationItems"
+              :key="item.name ?? item.title"
+            >
+              <template v-if="item.permission">
+                <q-item
+                  v-if="item.type === 'single'"
+                  v-ripple
+                  active-class="menu-selected"
+                  clickable
+                  exact-active-class="menu-selected"
+                  :exact="item.name == 'HomePage'"
+                  :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
+                      color="dark"
+                      style="font-size: 20px"
+                      :name="item.icon"
+                    />
+                  </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-expansion-item
-                    v-model="isExpasionItemExpanded[index]"
-                    :to="item.name ? { name: item.name } : undefined"
-                    active-class=""
-                    exact-active-class=""
-                    :class="{
-                      'menu-selected':
-                        childrenAreActive(item.childrens, item.name) &&
-                        !isExpasionItemExpanded[index],
-                    }"
                   >
-                    <template #header>
-                      <q-item-section avatar>
-                        <q-icon :name="item.icon" style="font-size: 20px" color="dark" />
-                      </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="q-pl-lg"
-                      >
+                    {{ $t(item.title) }}
+                  </q-tooltip>
+                </q-item>
+
+                <div v-else>
+                  <template v-if="!miniState">
+                    <q-expansion-item
+                      v-model="isExpasionItemExpanded[index]"
+                      active-class=""
+                      exact-active-class=""
+                      :class="{
+                        'menu-selected':
+                          childrenAreActive(item.childrens, item.name) &&
+                          !isExpasionItemExpanded[index],
+                      }"
+                      :to="item.name ? { name: item.name } : undefined"
+                    >
+                      <template #header>
                         <q-item-section avatar>
-                          <q-icon :name="child.icon" style="font-size: 20px" color="dark" />
+                          <q-icon
+                            color="dark"
+                            style="font-size: 20px"
+                            :name="item.icon"
+                          />
                         </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-section>
+                          {{ $t(item.title) }}
+                        </q-item-section>
+                      </template>
+
+                      <div
+                        v-for="child in item.childrens"
+                        :key="child.name"
+                      >
+                        <q-item
+                          v-ripple
+                          class="q-pl-lg"
+                          clickable
+                          exact
+                          exact-active-class="menu-selected"
+                          :to="{ name: child.name }"
                         >
-                      </q-item>
-                    </div>
-                  </q-expansion-item>
-                </template>
-                <template v-else>
-                  <q-item
-                    v-ripple
-                    clickable
-                    :class="{
-                      'menu-selected': childrenAreActive(item.childrens, item.name),
-                    }"
-                  >
-                    <q-item-section avatar>
-                      <q-icon :name="item.icon" style="font-size: 20px" color="dark" />
-                    </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-item-section avatar>
+                            <q-icon
+                              color="dark"
+                              style="font-size: 20px"
+                              :name="child.icon"
+                            />
+                          </q-item-section>
+
+                          <q-item-section>
+                            {{ $t(child.title) }}
+                          </q-item-section>
+                        </q-item>
+                      </div>
+                    </q-expansion-item>
+                  </template>
+
+                  <template v-else>
+                    <q-item
+                      v-ripple
+                      clickable
+                      :class="{
+                        'menu-selected': childrenAreActive(
+                          item.childrens,
+                          item.name,
+                        ),
+                      }"
                     >
-                      <q-list>
-                        <template v-if="item.name">
+                      <q-item-section avatar>
+                        <q-icon
+                          color="dark"
+                          style="font-size: 20px"
+                          :name="item.icon"
+                        />
+                      </q-item-section>
+
+                      <q-item-section>
+                        {{ $t(item.title) }}
+                      </q-item-section>
+
+                      <q-tooltip
+                        v-if="miniState"
+                        anchor="center right"
+                        self="center left"
+                        :offset="[10, 10]"
+                      >
+                        {{ $t(item.title) }}
+                      </q-tooltip>
+
+                      <q-menu
+                        anchor="center right"
+                        class="menu-drawer"
+                        self="top start"
+                      >
+                        <q-list>
+                          <template v-if="item.name">
+                            <q-item
+                              v-ripple
+                              v-close-popup
+                              class="menu-drawer"
+                              clickable
+                              exact
+                              exact-active-class="menu-selected"
+                              :to="{ name: item.name }"
+                            >
+                              <q-item-section avatar>
+                                <q-icon
+                                  color="dark"
+                                  style="font-size: 20px"
+                                  :name="item.icon"
+                                />
+                              </q-item-section>
+
+                              <q-item-section>
+                                {{ $t(item.title) }}
+                              </q-item-section>
+                            </q-item>
+
+                            <q-separator />
+                          </template>
+
                           <q-item
+                            v-for="child in item.childrens"
+                            :key="child.name"
                             v-ripple
                             v-close-popup
+                            class="menu-drawer"
                             clickable
-                            :to="{ name: item.name }"
                             exact
                             exact-active-class="menu-selected"
-                            class="menu-drawer"
+                            :to="{ name: child.name }"
                           >
                             <q-item-section avatar>
                               <q-icon
-                                :name="item.icon"
-                                style="font-size: 20px"
                                 color="dark"
+                                style="font-size: 20px"
+                                :name="child.icon"
                               />
                             </q-item-section>
-                            <q-item-section>{{ $t(item.title) }}</q-item-section>
+
+                            <q-item-section>
+                              {{ $t(child.title) }}
+                            </q-item-section>
                           </q-item>
-                          <q-separator />
-                        </template>
-                        <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"
-                              color="dark"
-                            />
-                          </q-item-section>
-                          <q-item-section>{{ $t(child.title) }}</q-item-section>
-                        </q-item>
-                      </q-list>
-                    </q-menu>
-                  </q-item>
-                </template>
-              </div>
+                        </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>
+        </q-scroll-area>
+
+        <q-list class="column q-mb-md no-wrap" style="border-radius: 6px" />
+
         <q-list class="q-mt-auto">
           <q-item v-ripple clickable @click="logoutFn">
             <div class="flex q-mx-auto">
               <q-item-section avatar>
                 <div class="flex q-mx-auto q-gutter-x-xs">
                   <q-icon
-                    name="logout"
                     color="negative"
+                    name="logout"
                     style="font-size: 20px"
                   />
 
@@ -226,17 +273,25 @@
                 </div>
               </q-item-section>
             </div>
+
             <q-tooltip
               v-if="miniState"
               anchor="center right"
               self="center left"
               :offset="[10, 10]"
-              >{{ $t("auth.logout") }}</q-tooltip
             >
+              {{ $t("auth.logout") }}
+            </q-tooltip>
           </q-item>
-          <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
+
+          <q-item
+            v-ripple
+            clickable
+            @click="openUrl('https://softpar.inf.br')"
+          >
             <div class="flex full-width justify-center">
               <q-img
+                style="width: 100%; height: 30px"
                 :src="
                   miniState || $q.screen.lt.md
                     ? LogoSoftparMini
@@ -244,7 +299,6 @@
                       ? LogoSoftparLight
                       : LogoSoftparDark
                 "
-                style="width: 100%; height: 30px"
                 :style="
                   miniState || $q.screen.lt.md
                     ? 'max-width: 48px'
@@ -254,61 +308,69 @@
             </div>
           </q-item>
         </q-list>
+
         <div
           class="full-width text-center q-pb-xs cursor-pointer text-dark"
-          @click="gotoVersionPage()"
+          @click="gotoVersionPage"
         >
-          <span v-if="!(miniState || $q.screen.lt.md)" class="text-caption">{{
-            $t("common.terms.version") + " " + version
-          }}</span>
-          <span v-else class="text-caption">{{ version }}</span>
+          <span
+            v-if="!(miniState || $q.screen.lt.md)"
+            class="text-caption"
+          >
+            {{ $t("common.terms.version") + " " + version }}
+          </span>
+
+          <span v-else class="text-caption">
+            {{ version }}
+          </span>
         </div>
       </div>
     </div>
   </q-drawer>
 </template>
+
 <script setup>
-import { ref, watch, watchEffect, onMounted } from "vue";
-import { useAuth } from "src/composables/useAuth";
-import { useRouter, useRoute } from "vue-router";
+import { Cookies, useQuasar } from "quasar";
 import { navigationStore } from "src/stores/navigation";
-import { useQuasar, Cookies } from "quasar";
+import { onMounted, ref, watch, watchEffect } from "vue";
+import { useAuth } from "src/composables/useAuth";
+
+import { useRoute, useRouter } from "vue-router";
+
 import { version } from "src/../package.json";
 
 import Logo from "src/assets/images/logo.svg";
-import MiniLogo from "src/assets/images/mini-logo.svg";
-import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
+import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparMini from "src/assets/softpar_logo_mini.svg";
+import MiniLogo from "src/assets/images/mini-logo.svg";
 
 const { logout } = useAuth();
-const router = useRouter();
+const $q = useQuasar();
 const route = useRoute();
+const router = useRouter();
+
 const navigation_store = navigationStore();
-const $q = useQuasar();
+
+const isExpasionItemExpanded = ref([]);
 
 const miniStateCookies = Cookies.get("miniState");
-const miniState = ref(miniStateCookies === "true" ? true : false);
+const miniState = ref(miniStateCookies === "true");
 
 const childrenAreActive = (children, parentName = null) => {
   if (parentName && route?.name === parentName) return true;
   if (!Array.isArray(children) || children.length === 0) return false;
+
   return children.some((child) => route?.name === child?.name);
 };
 
-const isExpasionItemExpanded = ref([]);
-
-watchEffect(() => {
-  if ($q.screen.lt.md) {
-    miniState.value = true;
-    isExpasionItemExpanded.value.forEach((expansion, index) => {
-      isExpasionItemExpanded.value[index] = false;
-    });
-  }
-});
+const gotoVersionPage = () => {
+  router.push({ name: "SystemVersionsPage" });
+};
 
 const logoutFn = async () => {
   await logout();
+
   router.push({ name: "LoginPage" });
 };
 
@@ -316,12 +378,21 @@ const openUrl = (url) => {
   window.open(url, "_blank");
 };
 
-const gotoVersionPage = () => {
-  router.push({ name: "SystemVersionsPage" });
-};
-
 watch(miniState, () => {
-  Cookies.set("miniState", miniState.value, { path: "/", sameSite: "Lax" });
+  Cookies.set("miniState", miniState.value, {
+    path: "/",
+    sameSite: "Lax",
+  });
+});
+
+watchEffect(() => {
+  if ($q.screen.lt.md) {
+    miniState.value = true;
+
+    isExpasionItemExpanded.value.forEach((expansion, index) => {
+      isExpasionItemExpanded.value[index] = false;
+    });
+  }
 });
 
 onMounted(() => {
@@ -336,11 +407,26 @@ onMounted(() => {
 <style lang="scss" scoped>
 @import "/src/css/quasar.variables.scss";
 
+.menu-content {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
 .menu-selected {
   background-color: $secondary;
   color: $dark;
 }
 
+.menu-scroll-area {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+.q-drawer-container {
+  background-image: url("/images/background-opacity.png");
+  background-position: 15%;
+}
+
 .toggle-button-wrapper {
   transition: transform 0.3s ease;
 }
@@ -348,9 +434,4 @@ onMounted(() => {
 .toggle-button-wrapper:hover {
   transform: scale(1.1);
 }
-
-.q-drawer-container {
-  background-image: url("/images/background-opacity.png");
-  background-position: 15%;
-}
-</style>
+</style>

+ 121 - 72
src/components/layout/LeftMenuLayoutMobile.vue

@@ -3,79 +3,111 @@
   <q-drawer
     v-bind="$attrs"
     v-model="leftDrawerOpen"
-    :width="214"
-    :breakpoint="500"
     behavior="mobile"
     class="detached-container"
+    :breakpoint="500"
+    :width="214"
   >
     <div class="column full-height no-wrap">
-      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
+      <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" />
+          <q-img style="max-width: 92px" :src="Logo" />
         </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: 20px" />
-                </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.icon"
-                v-model="isExpasionItemExpanded"
-                :class="{
-                  'menu-selected':
-                    childrenAreActive(item.children) && !isExpasionItemExpanded,
-                }"
-              >
-                <template #header>
+      <div class="column no-wrap menu-content">
+        <q-scroll-area class="menu-scroll-area">
+          <q-list class="column no-wrap">
+            <template
+              v-for="item in navigation_store.navigationItems"
+              :key="item.name ?? item.icon"
+            >
+              <template v-if="item.permission">
+                <q-item
+                  v-if="item.type === 'single'"
+                  v-ripple
+                  active-class="menu-selected"
+                  clickable
+                  exact-active-class="menu-selected"
+                  :exact="item.name == 'HomePage'"
+                  :to="{ name: item.name }"
+                >
                   <q-item-section avatar>
-                    <q-icon :name="item.icon" style="font-size: 20px" />
+                    <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="q-pl-lg"
-                  >
+
+                  <q-item-section>
+                    {{ $t(item.title) }}
+                  </q-item-section>
+                </q-item>
+
+                <q-expansion-item
+                  v-else
+                  v-model="isExpasionItemExpanded"
+                  :class="{
+                    'menu-selected':
+                      childrenAreActive(item.childrens) &&
+                      !isExpasionItemExpanded,
+                  }"
+                >
+                  <template #header>
                     <q-item-section avatar>
-                      <q-icon :name="child.icon" style="font-size: 20px" />
+                      <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
+                      class="q-pl-lg"
+                      clickable
+                      exact
+                      exact-active-class="menu-selected"
+                      :to="{ name: child.name }"
+                    >
+                      <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-scroll-area>
 
         <q-list class="q-mt-auto">
-          <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
+          <q-item
+            v-ripple
+            clickable
+            @click="openUrl('https://softpar.inf.br')"
+          >
             <div class="flex full-width justify-center">
               <q-img
                 :src="LogoSoftparDark"
@@ -84,13 +116,14 @@
             </div>
           </q-item>
         </q-list>
+
         <div
           class="full-width text-center text-subtitle3 cursor-pointer"
-          @click="gotoVersionPage()"
+          @click="gotoVersionPage"
         >
-          <span class="text-caption text-weight-light">{{
-            $t("common.terms.version") + " " + version
-          }}</span>
+          <span class="text-caption text-weight-light">
+            {{ $t("common.terms.version") + " " + version }}
+          </span>
         </div>
       </div>
     </div>
@@ -98,36 +131,41 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import { useRoute, useRouter } from "vue-router";
 import { navigationStore } from "src/stores/navigation";
+import { onMounted, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
 import { version } from "src/../package.json";
 
 import Logo from "src/assets/logo.png";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
 
-const router = useRouter();
-const route = useRoute();
+const leftDrawerOpen = defineModel({
+  type: Boolean,
+});
+
 const navigation_store = navigationStore();
 
-const leftDrawerOpen = defineModel({ type: Boolean });
+const route = useRoute();
+const router = useRouter();
+
 const isExpasionItemExpanded = ref([]);
 
 const childrenAreActive = (children) => {
   if (!Array.isArray(children) || children.length === 0) {
     return false;
   }
-  return children.some((child) => route?.name === child?.name);
-};
 
-const openUrl = (url) => {
-  window.open(url, "_blank");
+  return children.some((child) => route?.name === child?.name);
 };
 
 const gotoVersionPage = () => {
   router.push({ name: "SystemVersionsPage" });
 };
 
+const openUrl = (url) => {
+  window.open(url, "_blank");
+};
+
 onMounted(() => {
   navigation_store.navigationItems.forEach((item, index) => {
     if (childrenAreActive(item.childrens)) {
@@ -139,13 +177,24 @@ onMounted(() => {
 
 <style lang="scss" scoped>
 @import "/src/css/quasar.variables.scss";
-.text-subtitle3 {
-  font-size: 1.1rem !important;
-  font-weight: 400 !important;
+
+.menu-content {
+  flex: 1 1 auto;
+  min-height: 0;
 }
 
 .menu-selected {
   background-color: rgba($primary, 0.1);
   color: $primary;
 }
-</style>
+
+.menu-scroll-area {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+.text-subtitle3 {
+  font-size: 1.1rem !important;
+  font-weight: 400 !important;
+}
+</style>

+ 47 - 55
src/components/shared/ChangeImageDialog.vue

@@ -1,34 +1,49 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="min-width: 400px">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="min-width: 400px"
+    >
       <DefaultDialogHeader title="Trocar Imagem" @close="onDialogCancel" />
 
-      <q-card-section class="q-pt-none q-pb-sm">
-        <div class="text-caption text-grey-6 q-mb-xs">Personalizar</div>
-        <q-file
+      <q-card-section class="row q-col-gutter-md q-pt-none">
+        <div class="col-12 text-caption text-grey-6">
+          Personalizar
+        </div>
+
+        <DefaultMediaPicker
           v-model="selectedFile"
+          v-model:preview-url="previewUrl"
           accept="image/*"
-          outlined
+          class="col-12"
           dense
+          label=""
           placeholder="Buscar no Desktop"
-          @update:model-value="onFileSelected"
-        >
-          <template #append>
-            <q-icon name="search" />
-          </template>
-        </q-file>
+          preview-height="200"
+          show-preview
+          :label-outside="false"
+        />
       </q-card-section>
 
-      <q-card-section class="q-pt-none">
-        <div class="text-caption text-grey-6 q-mb-xs">Pré - Visualização</div>
-        <div class="preview-area flex flex-center">
-          <img v-if="previewUrl" :src="previewUrl" class="preview-image" />
-        </div>
-      </q-card-section>
+      <q-separator />
+
+      <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+        <q-btn
+          color="negative"
+          label="Cancelar"
+          no-caps
+          outline
+          @click="onDialogCancel"
+        />
 
-      <q-card-actions align="right">
-        <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
-        <q-btn color="primary" label="Salvar" :disable="!selectedFile" @click="onSave" />
+        <q-btn
+          color="primary"
+          label="Salvar"
+          no-caps
+          unelevated
+          :disable="!selectedFile"
+          @click="onSave"
+        />
       </q-card-actions>
     </q-card>
   </q-dialog>
@@ -37,45 +52,22 @@
 <script setup>
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultMediaPicker from "src/components/defaults/DefaultMediaPicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
 
-const selectedFile = ref(null);
 const previewUrl = ref(null);
+const selectedFile = ref(null);
 
-function onFileSelected(file) {
-  if (!file) {
-    previewUrl.value = null;
-    return;
-  }
-  const reader = new FileReader();
-  reader.onload = (e) => {
-    previewUrl.value = e.target.result;
-  };
-  reader.readAsDataURL(file);
-}
-
-function onSave() {
-  onDialogOK({ file: selectedFile.value, previewUrl: previewUrl.value });
-}
-</script>
-
-<style scoped>
-.preview-area {
-  width: 100%;
-  height: 200px;
-  border: 1px solid #e0e0e0;
-  border-radius: 4px;
-  background-color: #fafafa;
-  overflow: hidden;
-}
-
-.preview-image {
-  max-width: 100%;
-  max-height: 100%;
-  object-fit: contain;
-}
-</style>
+const onSave = () => {
+  onDialogOK({
+    file: selectedFile.value,
+    previewUrl: previewUrl.value,
+  });
+};
+</script>

+ 9 - 0
src/composables/useAuth.js

@@ -1,13 +1,18 @@
 import api from "src/api";
+
 import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";
 
 export const useAuth = () => {
   const setAuthDataFromPayload = async (tokens) => {
     const { access_token, user } = tokens;
+
     // setUser (e não user =) para que isAdmin seja derivado do perfil.
+
     userStore().setUser(user);
+
     userStore().accessToken = access_token;
+
     await permissionStore().fetchScopes();
   };
 
@@ -22,6 +27,7 @@ export const useAuth = () => {
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);
       }
+
       return response;
     } catch (error) {
       return Promise.reject(error);
@@ -31,6 +37,7 @@ export const useAuth = () => {
   const logout = async () => {
     try {
       const response = await api.post("/logout");
+
       if (response.status === 200) {
         userStore().resetUser();
       }
@@ -42,9 +49,11 @@ export const useAuth = () => {
   const refresh = async () => {
     try {
       const response = await api.post("/refresh");
+
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);
       }
+
       return response;
     } catch (error) {
       return Promise.reject(error);

+ 79 - 92
src/composables/useFormUpdateTracker.js → src/composables/useForm.js

@@ -1,11 +1,12 @@
-import { reactive, computed, toRaw, isReactive, nextTick, watch } from "vue";
+import { computed, isReactive, reactive, toRaw, watch } from "vue";
+
 import isEqual from "fast-deep-equal";
-import { useScroll } from "src/composables/useScroll";
 
-export const useFormUpdateTracker = (initialFormValue, options = {}) => {
+export const useForm = (initialFormValue) => {
   const form = reactive(deepClone(initialFormValue));
-  const { scrollToComponent } = useScroll();
+
   let originalForm = deepClone(initialFormValue);
+
   const updatedFields = reactive({});
 
   const getUpdatedFields = computed(() => {
@@ -16,35 +17,19 @@ export const useFormUpdateTracker = (initialFormValue, options = {}) => {
     return Object.keys(updatedFields).length > 0;
   });
 
-  watch(
-    form,
-    (newValue) => {
-      const changes = diff(toRaw(newValue), originalForm);
-      Object.keys(updatedFields).forEach((key) => delete updatedFields[key]);
-      Object.assign(updatedFields, changes);
-    },
-    { deep: true },
-  );
-
-  const resetUpdateForm = () => {
-    const newFormState = deepClone(originalForm);
-    Object.keys(form).forEach((key) => delete form[key]);
-    Object.assign(form, newFormState);
-  };
-
-  const setUpdateFormAsOriginal = () => {
-    originalForm = deepClone(toRaw(form));
-  };
-
   const getFormAsFormData = () => {
     const formData = new FormData();
+
     buildFormData(formData, form);
+
     return formData;
   };
 
   const getUpdatedFieldsAsFormData = (spoofMethod = null) => {
     const formData = new FormData();
+
     buildFormData(formData, updatedFields);
+
     if (spoofMethod) {
       formData.append("_method", spoofMethod.toUpperCase());
     }
@@ -52,84 +37,47 @@ export const useFormUpdateTracker = (initialFormValue, options = {}) => {
     return formData;
   };
 
-  const onValidationError = async (invalidComponent) => {
-    if (!invalidComponent) return;
-    await nextTick();
-    invalidComponent.focus?.();
-    scrollToComponent(invalidComponent, options.containerRef);
+  const resetUpdateForm = () => {
+    const newFormState = deepClone(originalForm);
+
+    Object.keys(form).forEach((key) => delete form[key]);
+    Object.assign(form, newFormState);
+  };
+
+  const setUpdateFormAsOriginal = () => {
+    originalForm = deepClone(toRaw(form));
   };
 
+  watch(
+    form,
+    (newValue) => {
+      const changes = diff(toRaw(newValue), originalForm);
+
+      Object.keys(updatedFields).forEach((key) => delete updatedFields[key]);
+      Object.assign(updatedFields, changes);
+    },
+    { deep: true },
+  );
+
   return {
     form,
     getUpdatedFields,
     hasUpdatedFields,
-    resetUpdateForm,
-    setUpdateFormAsOriginal,
     getFormAsFormData,
     getUpdatedFieldsAsFormData,
-    onValidationError,
+    resetUpdateForm,
+    setUpdateFormAsOriginal,
   };
 };
 
-/**
- * A recursive function to find the differences between two objects.
- * It returns a new object containing only the keys that have been added,
- * changed, or removed (set to null).
- * @param {object} currentObj The current state of the object.
- * @param {object} baseObj The original object to compare against.
- * @returns {object} An object with only the changed, new, or deleted keys.
- */
-function diff(currentObj, baseObj) {
-  const changes = {};
-  const currentKeys = Object.keys(currentObj);
-  const baseKeys = Object.keys(baseObj);
-
-  for (const key of currentKeys) {
-    const currentValue = currentObj[key];
-    const baseValue = baseObj[key];
-
-    if (!isEqual(currentValue, baseValue)) {
-      if (
-        currentValue &&
-        typeof currentValue === "object" &&
-        !Array.isArray(currentValue) &&
-        baseValue &&
-        typeof baseValue === "object" &&
-        !Array.isArray(baseValue)
-      ) {
-        const nestedChanges = diff(currentValue, baseValue);
-        if (Object.keys(nestedChanges).length > 0) {
-          changes[key] = nestedChanges;
-        }
-      } else {
-        changes[key] = deepClone(currentValue);
-      }
-    }
-  }
-
-  for (const key of baseKeys) {
-    if (!Object.prototype.hasOwnProperty.call(currentObj, key)) {
-      changes[key] = null;
-    }
-  }
-
-  return changes;
-}
-
-/**
- * Recursively builds a FormData object from a nested plain object using bracket notation
- * for keys, which is compatible with PHP/Laravel backends.
- * @param {FormData} formData The FormData instance.
- * @param {object} data The plain object to serialize.
- * @param {string} parentKey The base key for nested properties.
- */
-function buildFormData(formData, data, parentKey = "") {
+const buildFormData = (formData, data, parentKey = "") => {
   if (data === undefined) {
     return;
   }
 
   if (data == null) {
     formData.append(parentKey, null);
+
     return;
   }
 
@@ -137,6 +85,7 @@ function buildFormData(formData, data, parentKey = "") {
     data.forEach((value, index) => {
       buildFormData(formData, value, `${parentKey}[${index}]`);
     });
+
     return;
   }
 
@@ -147,12 +96,15 @@ function buildFormData(formData, data, parentKey = "") {
   ) {
     Object.keys(data).forEach((key) => {
       const propName = parentKey ? `${parentKey}[${key}]` : key;
+
       buildFormData(formData, data[key], propName);
     });
+
     return;
   }
 
   let valueToAppend = data;
+
   if (data instanceof Date) {
     valueToAppend = data.toISOString().slice(0, 19).replace("T", " ");
   }
@@ -160,16 +112,11 @@ function buildFormData(formData, data, parentKey = "") {
   formData.append(parentKey, valueToAppend);
 }
 
-/** * Deep clones an object using structuredClone if available,
- * otherwise falls back to JSON methods.
- * If the object is reactive, it converts it to a raw object first.
- * @param {object} obj The object to clone.
- * @returns {object} A deep clone of the input object.
- */
-function deepClone(obj) {
+const deepClone = (obj) => {
   if (obj && isReactive(obj)) {
     obj = toRaw(obj);
   }
+
   if (typeof structuredClone === "function") {
     try {
       return structuredClone(obj);
@@ -179,5 +126,45 @@ function deepClone(obj) {
       );
     }
   }
+
   return JSON.parse(JSON.stringify(obj));
 }
+
+const diff = (currentObj, baseObj) => {
+  const changes = {};
+  const currentKeys = Object.keys(currentObj);
+  const baseKeys = Object.keys(baseObj);
+
+  for (const key of currentKeys) {
+    const currentValue = currentObj[key];
+
+    const baseValue = baseObj[key];
+
+    if (!isEqual(currentValue, baseValue)) {
+      if (
+        currentValue &&
+        typeof currentValue === "object" &&
+        !Array.isArray(currentValue) &&
+        baseValue &&
+        typeof baseValue === "object" &&
+        !Array.isArray(baseValue)
+      ) {
+        const nestedChanges = diff(currentValue, baseValue);
+
+        if (Object.keys(nestedChanges).length > 0) {
+          changes[key] = nestedChanges;
+        }
+      } else {
+        changes[key] = deepClone(currentValue);
+      }
+    }
+  }
+
+  for (const key of baseKeys) {
+    if (!Object.prototype.hasOwnProperty.call(currentObj, key)) {
+      changes[key] = null;
+    }
+  }
+
+  return changes;
+}

+ 97 - 44
src/composables/useInputRules.js

@@ -3,110 +3,163 @@ import { useI18n } from "vue-i18n";
 export const useInputRules = () => {
   const { t } = useI18n();
 
+  const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
+
   const emailPattern =
     /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
   const passwordPattern = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/;
-  const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
 
   const inputRules = {
-    required: (value) => !!value || t("validation.rules.required"),
-    requiredNumber: (value) => !isNaN(value) || t("validation.rules.required"),
-    requiredHideMessage: (value) => !!value,
-    min: (min) => (value) =>
-      value.length >= min ||
-      `${t("validation.rules.min")} ${min} ${t("validation.rules.characters")}`,
-    max: (max) => (value) =>
-      value.length <= max ||
-      `${t("validation.rules.max")} ${max} ${t("validation.rules.characters")}`,
-    minValue: (min) => (value) =>
-      value >= min || `${t("validation.rules.min")} ${min}`,
-    maxValue: (max) => (value) =>
-      value <= max || `${t("validation.rules.max")} ${max}`,
+    cep: (value) => {
+      if (!value) return true;
+      return cepPattern.test(value) || t("validation.rules.cep");
+    },
+
+    cnpj: (value) => !value || isValidCNPJ(value) || t("validation.rules.cnpj"),
+    cpf: (value) => !value || isValidCPF(value) || t("validation.rules.cpf"),
+
     email: (value) =>
       !value || emailPattern.test(value) || t("validation.rules.email"),
+
     emails: (value) => {
       if (!value) return true;
+
       const emails = value.split(";").map((email) => email.trim());
+
       return (
         emails.every((email) => inputRules.email(email) === true) ||
         t("validation.rules.email")
       );
     },
-    cpf: (value) => !value || isValidCPF(value) || t("validation.rules.cpf"),
-    cnpj: (value) => !value || isValidCNPJ(value) || t("validation.rules.cnpj"),
-    samePassword: (otherValue) => (value) =>
-      value === otherValue || t("validation.rules.same_password"),
-    password: (value) =>
-      !value || passwordPattern.test(value) || t("validation.rules.password"),
-    cep: (value) => {
-      if (!value) return true;
-      return cepPattern.test(value) || t("validation.rules.cep");
-    },
+
+    min: (min) => (value) =>
+      value.length >= min ||
+      `${t("validation.rules.min")} ${min} ${t("validation.rules.characters")}`,
+
+    max: (max) => (value) =>
+      value.length <= max ||
+      `${t("validation.rules.max")} ${max} ${t("validation.rules.characters")}`,
+
+    minValue: (min) => (value) =>
+      value >= min || `${t("validation.rules.min")} ${min}`,
+
+    maxValue: (max) => (value) =>
+      value <= max || `${t("validation.rules.max")} ${max}`,
+
     notSameDocument: (allDocuments) => (value) => {
       if (!value) return true;
+
       let found = 0;
+
       for (const doc of allDocuments) {
         if (doc == value) {
           found++;
         }
+
         if (found > 1) {
           return t("validation.rules.not_same_document");
         }
       }
+
       return true;
     },
+
+    password: (value) =>
+      !value || passwordPattern.test(value) || t("validation.rules.password"),
+
+    samePassword: (otherValue) => (value) =>
+      value === otherValue || t("validation.rules.same_password"),
+
+    //
+
+    required: (value) => !!value || t("validation.rules.required"),
+    requiredHideMessage: (value) => !!value,
+
+    requiredNumber: (value) =>
+      (value !== null &&
+        value !== undefined &&
+        value !== "" &&
+        !Number.isNaN(Number(value))) ||
+      t("validation.rules.required"),
   };
 
-  inputRules.required.$id = 'required'
+  inputRules.required.$id = "required";
+  inputRules.requiredNumber.$id = "required";
+  inputRules.requiredHideMessage.$id = "required";
 
   return {
     inputRules,
   };
 };
 
-function isValidCPF(cpf) {
-  if (!cpf) return false;
-  cpf = cpf.replace(/[^\d]+/g, "");
-  if (cpf.length !== 11) return false;
-  if (/^(\d)\1+$/.test(cpf)) return false;
-  let sum = 0;
-  for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i);
-  let rev = 11 - (sum % 11);
-  if (rev === 10 || rev === 11) rev = 0;
-  if (rev !== parseInt(cpf.charAt(9))) return false;
-  sum = 0;
-  for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i);
-  rev = 11 - (sum % 11);
-  if (rev === 10 || rev === 11) rev = 0;
-  if (rev !== parseInt(cpf.charAt(10))) return false;
-  return true;
-}
-
-function isValidCNPJ(cnpj) {
+const isValidCNPJ = (cnpj) => {
   if (!cnpj) return false;
+
   cnpj = cnpj.replace(/[^\d]+/g, "");
+
   if (cnpj.length !== 14) return false;
   if (/^(\d)\1+$/.test(cnpj)) return false;
+
   let length = cnpj.length - 2;
   let numbers = cnpj.substring(0, length);
   let digits = cnpj.substring(length);
   let sum = 0;
   let pos = length - 7;
+
   for (let i = length; i >= 1; i--) {
     sum += parseInt(numbers.charAt(length - i)) * pos--;
+
     if (pos < 2) pos = 9;
   }
+
   let result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+
   if (result !== parseInt(digits.charAt(0))) return false;
+
   length = length + 1;
   numbers = cnpj.substring(0, length);
   sum = 0;
   pos = length - 7;
+
   for (let i = length; i >= 1; i--) {
     sum += parseInt(numbers.charAt(length - i)) * pos--;
+
     if (pos < 2) pos = 9;
   }
+
   result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+
   if (result !== parseInt(digits.charAt(1))) return false;
+
+  return true;
+}
+
+const isValidCPF = (cpf) => {
+  if (!cpf) return false;
+
+  cpf = cpf.replace(/[^\d]+/g, "");
+
+  if (cpf.length !== 11) return false;
+  if (/^(\d)\1+$/.test(cpf)) return false;
+
+  let sum = 0;
+
+  for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i);
+
+  let rev = 11 - (sum % 11);
+
+  if (rev === 10 || rev === 11) rev = 0;
+  if (rev !== parseInt(cpf.charAt(9))) return false;
+
+  sum = 0;
+
+  for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i);
+
+  rev = 11 - (sum % 11);
+
+  if (rev === 10 || rev === 11) rev = 0;
+  if (rev !== parseInt(cpf.charAt(10))) return false;
+
   return true;
 }

+ 2 - 0
src/composables/useScroll.js

@@ -42,6 +42,8 @@ import { nextTick, unref } from "vue";
       };
     };
 
+    //
+
     const calculatePosition = (targetElement, containerElement, offset) => {
       const targetRect = targetElement.getBoundingClientRect();
 

+ 25 - 12
src/composables/useSubmitHandler.js

@@ -1,33 +1,29 @@
-import { ref, nextTick } from "vue";
+import { nextTick, ref } from "vue";
 import { useScroll } from "src/composables/useScroll";
 
-export function useSubmitHandler(options = {}) {
-  const { onSuccess, onError, formRef, scrollFn, containerRef } = options;
+export const useSubmitHandler = (options = {}) => {
+  const { containerRef, formRef, onError, onSuccess, scrollFn } = options;
+
   const { scrollToFirstError } = useScroll();
 
   const loading = ref(false);
-  const validationErrors = ref({});
-
-  const getFormRefs = () => {
-    const refs = formRef?.value;
-    if (!refs) return [];
-    return Array.isArray(refs) ? refs : [refs];
-  };
 
-  const showFirstError = () =>
-    scrollToFirstError(formRef, containerRef, { scrollFn });
+  const validationErrors = ref({});
 
   const execute = async (apiCallThunk) => {
     loading.value = true;
+
     validationErrors.value = {};
 
     let allValid = true;
+
     const refsToValidate = getFormRefs();
 
     if (refsToValidate.length > 0) {
       for (const ref of refsToValidate) {
         if (ref) {
           const success = await ref.validate(true);
+
           if (!success) {
             allValid = false;
           }
@@ -37,12 +33,15 @@ export function useSubmitHandler(options = {}) {
 
     if (!allValid) {
       loading.value = false;
+
       await showFirstError();
+
       throw new Error("Frontend validation failed.");
     }
 
     try {
       const response = await apiCallThunk();
+
       if (typeof onSuccess === "function") {
         await onSuccess(response);
       } else {
@@ -55,17 +54,29 @@ export function useSubmitHandler(options = {}) {
     }
   };
 
+  const getFormRefs = () => {
+    const refs = formRef?.value;
+
+    if (!refs) return [];
+
+    return Array.isArray(refs) ? refs : [refs];
+  };
+
   const handleError = async (error) => {
     if (error?.response?.status === 422) {
       const errors = error.response.data.errors || {};
+
       for (const key in errors) {
         const message = errors[key][0];
+
         validationErrors.value[key] = message;
       }
+
       await showFirstError();
     }
 
     await nextTick();
+
     if (typeof onError === "function") {
       await onError(error);
     } else {
@@ -73,6 +84,8 @@ export function useSubmitHandler(options = {}) {
     }
   };
 
+  const showFirstError = () => scrollToFirstError(formRef, containerRef, { scrollFn });
+
   return {
     loading,
     validationErrors,

+ 55 - 28
src/css/app.scss

@@ -17,12 +17,31 @@ body {
   flex-basis: 0;
 }
 
-.round {
-  border-radius: 50%;
+// Full-height pages with fixed tabs/toolbars and a scrollable content region.
+.tab-page-layout {
+  display: flex;
+  flex-direction: column;
+  height: calc(100dvh - env(safe-area-inset-top));
+  min-height: 0;
 }
 
-.self-center {
-  align-self: center;
+.tab-page-body,
+.tab-page-flex-content {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  min-height: 0;
+}
+
+.tab-page-scroll-area {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+@media (max-width: 599px) {
+  .tab-page-layout {
+    height: calc(100dvh - 68px - env(safe-area-inset-top));
+  }
 }
 
 .input-disable {
@@ -36,13 +55,6 @@ body {
   min-width: 0;
 }
 
-.q-toolbar {
-  position: relative;
-  padding: 0 12px;
-  min-height: 50px;
-  width: auto;
-}
-
 .q-drawer:has(.detached-container) {
   margin: 16px !important;
   margin-bottom: 16px !important;
@@ -52,7 +64,28 @@ body {
   color: #8b8b8b;
 }
 
+.q-toolbar {
+  position: relative;
+  padding: 0 12px;
+  min-height: 50px;
+  width: auto;
+}
+
+.round {
+  border-radius: 50%;
+}
+
+.self-center {
+  align-self: center;
+}
+
 body.body--light {
+  .q-dialog {
+    .q-card {
+      background: #{map.get($colors, "surface-dark")};
+    }
+  }
+
   .q-drawer:has(.detached-container) {
     background: #{map.get($colors, "surface")} !important;
   }
@@ -61,12 +94,6 @@ body.body--light {
     background: #{map.get($colors, "surface")};
   }
 
-  .q-dialog {
-    .q-card {
-      background: #{map.get($colors, "surface-dark")};
-    }
-  }
-
   background: #{map.get($colors, "background")} !important;
 
   .card-ring {
@@ -129,17 +156,17 @@ input[type="number"]::-webkit-outer-spin-button {
   margin: 0;
 }
 
-.q-separator {
-  padding-top: 0px;
-  padding-left: 0;
-}
-
 .q-scrollarea__content {
   display: flex;
   flex-direction: column;
   flex: 1 1 auto !important;
 }
 
+.q-separator {
+  padding-top: 0px;
+  padding-left: 0;
+}
+
 .remove-header-expansion-item {
   .q-list--dense > .q-item,
   .q-item--dense {
@@ -147,11 +174,11 @@ input[type="number"]::-webkit-outer-spin-button {
   }
 }
 
-.q-field--standout.q-field--rounded .q-field__control {
+.q-btn--rectangle {
   border-radius: 8px;
 }
 
-.q-btn--rectangle {
+.q-field--standout.q-field--rounded .q-field__control {
   border-radius: 8px;
 }
 
@@ -165,11 +192,11 @@ input[type="number"]::-webkit-outer-spin-button {
   }
 }
 
-.q-field--standout.q-field--highlighted .q-field__native {
-  color: $secondary;
-}
-
 .q-field--standout.q-field--highlighted .q-field__control {
   box-shadow: none;
   border-radius: 8px;
 }
+
+.q-field--standout.q-field--highlighted .q-field__native {
+  color: $secondary;
+}

+ 18 - 11
src/pages/cadastros/CadastrosPage.vue

@@ -1,31 +1,38 @@
 <template>
-  <div>
+  <div class="tab-page-layout">
     <DefaultHeaderPage title="Cadastros" :show-filter-icon="false" />
 
-    <div class="q-px-sm">
+    <div class="tab-page-body q-px-sm">
       <CustomTabComponent
         v-model:active-tab="currentTab"
-        :tabs="tabs"
         class="q-mb-sm"
+        :tabs="tabs"
       />
 
-      <div v-show="currentTab === 'usuarios'">
-        <UsuariosTab />
-      </div>
+      <q-scroll-area class="tab-page-scroll-area">
+        <div v-show="currentTab === 'usuarios'">
+          <UsuariosTab />
+        </div>
 
-      <div v-show="currentTab === 'grupos'">
-        <GruposTab />
-      </div>
+        <div v-show="currentTab === 'grupos'">
+          <GruposTab />
+        </div>
+      </q-scroll-area>
     </div>
   </div>
 </template>
 
 <script setup>
 import { defineAsyncComponent, ref } from "vue";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
 import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
 
-const UsuariosTab = defineAsyncComponent(() => import("./tabs/UsuariosTab.vue"));
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const UsuariosTab = defineAsyncComponent(
+  () => import("./tabs/UsuariosTab.vue"),
+);
+
 const GruposTab = defineAsyncComponent(() => import("./tabs/GruposTab.vue"));
 
 const currentTab = ref("usuarios");

+ 71 - 41
src/pages/cadastros/components/AddEditGroupDialog.vue

@@ -8,46 +8,56 @@
         />
 
         <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm column q-gutter-y-sm">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
               <DefaultInput
                 v-model="form.name"
+                v-model:error="validationErrors.name"
+                class="col-12"
                 label="Nome do Grupo"
                 outlined
+                :error-message="validationErrors.name"
                 :rules="[inputRules.required]"
               />
 
               <q-select
                 v-model="form.unit_ids"
-                :options="unitOptions"
+                class="col-12"
+                emit-value
+                hide-dropdown-icon
                 label="Unidades"
                 label-color="secondary"
-                outlined
+                map-options
                 multiple
+                outlined
                 use-chips
-                emit-value
-                map-options
-                hide-dropdown-icon
                 :loading="loadingUnits"
+                :options="unitOptions"
               >
                 <template #append>
-                  <q-icon name="mdi-chevron-down" color="secondary" />
+                  <q-icon color="secondary" name="mdi-chevron-down" />
                 </template>
               </q-select>
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
             <q-btn
+              color="negative"
               label="Cancelar"
+              no-caps
               outline
-              color="primary-2"
               @click="onDialogCancel"
             />
+
             <q-btn
+              color="primary"
               label="Salvar"
-              color="primary-2"
+              no-caps
               type="submit"
+              unelevated
               :loading="loading"
             />
           </q-card-actions>
@@ -58,70 +68,90 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { computed, onMounted, ref } from "vue";
+
+import {
+  createGroup,
+  updateGroup,
+} from "src/api/group";
+
+import { formatUnitName } from "src/helpers/utils";
+import { getUnitsForSelect } from "src/api/unit";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
-import { getUnitsForSelect } from "src/api/unit";
-import { createGroup, updateGroup } from "src/api/group";
-import { formatUnitName } from "src/helpers/utils";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
 const props = defineProps({
   group: {
-    type: Object,
     default: null,
+    type: Object,
   },
 });
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const { inputRules } = useInputRules();
 
+const { scrollToComponent } = useScroll();
+
 const formRef = ref(null);
-const loading = ref(false);
+const scrollAreaRef = ref(null);
+
+const { form, getUpdatedFields } = useForm(
+  {
+    name: props.group?.name ?? "",
+    unit_ids: props.group?.unit_ids ?? [],
+  },
+  { containerRef: scrollAreaRef },
+);
+
 const loadingUnits = ref(true);
 const unitOptions = ref([]);
 
 const isEdit = computed(() => !!props.group);
 
-const form = ref({
-  name: props.group?.name ?? "",
-  unit_ids: props.group?.unit_ids ?? [],
+const { loading, validationErrors, execute } = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
 });
 
-onMounted(async () => {
+const loadUnits = async () => {
   try {
     const units = await getUnitsForSelect();
-    unitOptions.value = units.map((u) => ({
-      label: formatUnitName(u),
-      value: u.id,
+
+    unitOptions.value = units.map((unit) => ({
+      label: formatUnitName(unit),
+      value: unit.id,
     }));
   } catch (error) {
     console.error("Failed to load units:", error);
   } finally {
     loadingUnits.value = false;
   }
-});
+};
 
 const onOKClick = async () => {
-  loading.value = true;
-  try {
-    const payload = {
-      name: form.value.name,
-      unit_ids: form.value.unit_ids,
-    };
+  const payload = {
+    name: form.name,
+    unit_ids: form.unit_ids,
+  };
 
-    const result = isEdit.value
-      ? await updateGroup(props.group.id, payload)
-      : await createGroup(payload);
-
-    onDialogOK(result);
-  } finally {
-    loading.value = false;
-  }
+  await execute(() =>
+    isEdit.value
+      ? updateGroup(props.group.id, getUpdatedFields.value)
+      : createGroup(payload),
+  );
 };
-</script>
+
+onMounted(loadUnits);
+</script>

+ 275 - 160
src/pages/dashboard/components/HolidaysDialog.vue

@@ -8,39 +8,42 @@
           <div class="col-12 col-sm-7">
             <div class="row items-center justify-between q-mb-md">
               <q-btn
-                flat
-                round
+                color="grey-7"
                 dense
+                flat
                 icon="mdi-chevron-left"
-                color="grey-7"
+                round
                 @click="prevMonth"
               />
+
               <div class="row items-center q-gutter-xs">
                 <q-select
                   v-model="currentMonth"
-                  :options="monthOptions"
+                  dense
                   emit-value
                   map-options
-                  dense
                   outlined
                   style="min-width: 100px"
+                  :options="monthOptions"
                 />
+
                 <q-select
                   v-model="currentYear"
-                  :options="yearOptions"
+                  dense
                   emit-value
                   map-options
-                  dense
                   outlined
                   style="min-width: 80px"
+                  :options="yearOptions"
                 />
               </div>
+
               <q-btn
-                flat
-                round
+                color="grey-7"
                 dense
+                flat
                 icon="mdi-chevron-right"
-                color="grey-7"
+                round
                 @click="nextMonth"
               />
             </div>
@@ -59,20 +62,23 @@
               <div
                 v-for="(cell, index) in calendarCells"
                 :key="index"
-                class="cal-cell"
                 :class="{
                   'cal-cell--empty': !cell.day,
                   'cal-cell--holiday': cell.isHoliday,
                 }"
+                class="cal-cell"
                 @click="cell.day && !cell.isHoliday && openNewRecord(cell.day)"
               >
-                <span v-if="cell.day" class="cal-cell__number">{{ cell.day }}</span>
+                <span v-if="cell.day" class="cal-cell__number">
+                  {{ cell.day }}
+                </span>
               </div>
             </div>
           </div>
 
           <div class="col-12 col-sm-5">
             <div class="text-subtitle2 q-mb-sm">Resumo</div>
+
             <q-separator class="q-mb-sm" />
 
             <div
@@ -92,20 +98,31 @@
               >
                 <q-item-section avatar>
                   <q-avatar
-                    size="36px"
+                    class="text-weight-bold"
                     color="deep-orange"
+                    size="36px"
                     text-color="white"
-                    class="text-weight-bold"
                   >
                     {{ holiday.day }}
                   </q-avatar>
                 </q-item-section>
+
                 <q-item-section>
-                  <q-item-label class="text-body2">{{ holiday.description }}</q-item-label>
-                  <q-item-label caption>{{ typeLabel(holiday.type) }}</q-item-label>
+                  <q-item-label class="text-body2">
+                    {{ holiday.description }}
+                  </q-item-label>
+
+                  <q-item-label caption>
+                    {{ typeLabel(holiday.type) }}
+                  </q-item-label>
                 </q-item-section>
+
                 <q-item-section side>
-                  <q-icon name="mdi-pencil-outline" color="grey-5" size="xs" />
+                  <q-icon
+                    color="grey-5"
+                    name="mdi-pencil-outline"
+                    size="xs"
+                  />
                 </q-item-section>
               </q-item>
             </q-list>
@@ -121,8 +138,14 @@
 
         <q-separator />
 
-        <q-card-actions align="right">
-          <q-btn outline color="primary" label="FECHAR" no-caps @click="onClose" />
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Fechar"
+            no-caps
+            outline
+            @click="onClose"
+          />
         </q-card-actions>
       </template>
 
@@ -132,79 +155,198 @@
           @close="cancelNewRecord"
         />
 
-        <q-card-section class="column q-gutter-md q-pt-sm">
-          <DefaultInput
-            v-model="newDescription"
-            label="Nome do Evento"
-            placeholder="Ex: Ponto facultativo, Natal..."
-            icon="mdi-pencil-outline"
-            outlined
-            autofocus
-          />
-
-          <DefaultSelect
-            v-model="newType"
-            label="Selecione o Tipo."
-            :options="typeOptions"
-            emit-value
-            map-options
-            outlined
-          />
-        </q-card-section>
-
-        <q-separator />
+        <DefaultForm ref="formRef" @submit="saveNewRecord">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
+              <DefaultInput
+                v-model="form.description"
+                v-model:error="validationErrors.description"
+                autofocus
+                class="col-12"
+                icon="mdi-pencil-outline"
+                label="Nome do Evento"
+                outlined
+                placeholder="Ex: Ponto facultativo, Natal..."
+                :error-message="validationErrors.description"
+                :rules="[inputRules.required, inputRules.max(255)]"
+              />
 
-        <q-card-actions align="right">
-          <q-btn outline color="primary" label="CANCELAR" no-caps @click="cancelNewRecord" />
-          <q-btn
-            unelevated
-            color="primary"
-            label="SALVAR"
-            no-caps
-            :disable="!newDescription.trim()"
-            :loading="saving"
-            @click="saveNewRecord"
-          />
-        </q-card-actions>
+              <DefaultSelect
+                v-model="form.type"
+                v-model:error="validationErrors.type"
+                class="col-12"
+                emit-value
+                label="Selecione o Tipo."
+                map-options
+                outlined
+                :error-message="validationErrors.type"
+                :options="typeOptions"
+              />
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+            <q-btn
+              color="negative"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="cancelNewRecord"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              unelevated
+              :loading="saving"
+            />
+          </q-card-actions>
+        </DefaultForm>
       </template>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
-import { useDialogPluginComponent, useQuasar } from "quasar";
+import { computed, ref } from "vue";
+import { createBaseHoliday } from "src/api/holiday";
+
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import { createBaseHoliday } from "src/api/holiday";
 import HolidayEditDialog from "src/pages/dashboard/components/HolidayEditDialog.vue";
 
+const props = defineProps({
+  initialHolidays: {
+    default: () => [],
+    type: Array,
+  },
+});
+
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
 const $q = useQuasar();
 
-const now = new Date();
-const currentMonth = ref(now.getMonth() + 1);
-const currentYear = ref(now.getFullYear());
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const { inputRules } = useInputRules();
 
-const view = ref("calendar");
-const selectedDay = ref(null);
-const newDescription = ref("");
-const newType = ref("feriado");
-const saving = ref(false);
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
 
-const props = defineProps({
-  initialHolidays: {
-    type: Array,
-    default: () => [],
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    description: "",
+    type: "feriado",
   },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onError: () => {
+    $q.notify({
+      message: "Erro ao salvar feriado.",
+      type: "negative",
+    });
+  },
+  scrollFn: scrollToComponent,
 });
 
+const currentMonth = ref(new Date().getMonth() + 1);
+const currentYear = ref(new Date().getFullYear());
 const holidays = ref([...props.initialHolidays]);
+const selectedDay = ref(null);
+const view = ref("calendar");
 
-const weekDays = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+const calendarCells = computed(() => {
+  const firstDay = new Date(
+    currentYear.value,
+    currentMonth.value - 1,
+    1,
+  ).getDay();
+
+  const daysInMonth = new Date(
+    currentYear.value,
+    currentMonth.value,
+    0,
+  ).getDate();
+
+  const holidayDays = new Set(
+    monthHolidays.value.map((holiday) => holiday.day),
+  );
+
+  const cells = [];
+
+  for (let day = 0; day < firstDay; day++) {
+    cells.push({
+      day: null,
+      isHoliday: false,
+    });
+  }
+
+  for (let day = 1; day <= daysInMonth; day++) {
+    cells.push({
+      day,
+      isHoliday: holidayDays.has(day),
+    });
+  }
+
+  return cells;
+});
+
+const formattedSelectedDate = computed(() => {
+  if (!selectedDay.value) return "";
+
+  const day = String(selectedDay.value).padStart(2, "0");
+  const month = String(currentMonth.value).padStart(2, "0");
+
+  return `${day}/${month}/${currentYear.value}`;
+});
+
+const monthHolidays = computed(() =>
+  holidays.value
+    .filter(
+      (holiday) =>
+        holiday.month === currentMonth.value &&
+        holiday.year === currentYear.value,
+    )
+    .sort((firstHoliday, secondHoliday) => firstHoliday.day - secondHoliday.day),
+);
+
+const yearOptions = computed(() => {
+  const baseYear = new Date().getFullYear();
+
+  return Array.from({ length: 7 }, (_, index) => {
+    const year = baseYear - 2 + index;
+
+    return {
+      label: String(year),
+      value: year,
+    };
+  });
+});
 
 const monthOptions = [
   { label: "Jan", value: 1 },
@@ -226,124 +368,97 @@ const typeOptions = [
   { label: "Ponto Facultativo", value: "facultativo" },
 ];
 
-const yearOptions = computed(() => {
-  const base = now.getFullYear();
-  return Array.from({ length: 7 }, (_, i) => {
-    const y = base - 2 + i;
-    return { label: String(y), value: y };
-  });
-});
+const weekDays = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
 
-const monthHolidays = computed(() =>
-  holidays.value
-    .filter((h) => h.month === currentMonth.value && h.year === currentYear.value)
-    .sort((a, b) => a.day - b.day),
-);
+const cancelNewRecord = () => {
+  selectedDay.value = null;
+  view.value = "calendar";
+};
 
-const calendarCells = computed(() => {
-  const year = currentYear.value;
-  const month = currentMonth.value;
-  const firstDay = new Date(year, month - 1, 1).getDay();
-  const daysInMonth = new Date(year, month, 0).getDate();
-  const holidayDays = new Set(monthHolidays.value.map((h) => h.day));
+const nextMonth = () => {
+  if (currentMonth.value === 12) {
+    currentMonth.value = 1;
+    currentYear.value += 1;
 
-  const cells = [];
-  for (let i = 0; i < firstDay; i++) cells.push({ day: null, isHoliday: false });
-  for (let d = 1; d <= daysInMonth; d++) cells.push({ day: d, isHoliday: holidayDays.has(d) });
-  return cells;
-});
+    return;
+  }
 
-const formattedSelectedDate = computed(() => {
-  if (!selectedDay.value) return "";
-  const dd = String(selectedDay.value).padStart(2, "0");
-  const mm = String(currentMonth.value).padStart(2, "0");
-  return `${dd}/${mm}/${currentYear.value}`;
-});
+  currentMonth.value += 1;
+};
 
-function typeLabel(type) {
-  return type === "facultativo" ? "Ponto Facultativo" : "Feriado";
-}
+const onClose = () => {
+  onDialogOK(holidays.value);
+};
 
-function prevMonth() {
+const openEdit = (holiday) => {
+  $q.dialog({
+    component: HolidayEditDialog,
+    componentProps: {
+      holiday,
+    },
+  }).onOk(({ action, holiday: updatedHoliday, id }) => {
+    if (action === "update") {
+      const index = holidays.value.findIndex(
+        (item) => item.id === updatedHoliday.id,
+      );
+
+      if (index !== -1) {
+        holidays.value[index] = updatedHoliday;
+      }
+    } else if (action === "delete") {
+      holidays.value = holidays.value.filter((item) => item.id !== id);
+    }
+  });
+};
+
+const openNewRecord = (day) => {
+  selectedDay.value = day;
+  form.description = "";
+  form.type = "feriado";
+  view.value = "new-record";
+};
+
+const prevMonth = () => {
   if (currentMonth.value === 1) {
     currentMonth.value = 12;
     currentYear.value -= 1;
-  } else {
-    currentMonth.value -= 1;
-  }
-}
 
-function nextMonth() {
-  if (currentMonth.value === 12) {
-    currentMonth.value = 1;
-    currentYear.value += 1;
-  } else {
-    currentMonth.value += 1;
+    return;
   }
-}
 
-function openNewRecord(day) {
-  selectedDay.value = day;
-  newDescription.value = "";
-  newType.value = "feriado";
-  view.value = "new-record";
-}
+  currentMonth.value -= 1;
+};
 
-function cancelNewRecord() {
-  view.value = "calendar";
-  selectedDay.value = null;
-}
+const saveNewRecord = async () => {
+  const description = form.description.trim();
 
-async function saveNewRecord() {
-  const desc = newDescription.value.trim();
-  if (!desc || selectedDay.value === null) return;
+  if (!description || selectedDay.value === null) return;
 
-  const mm = String(currentMonth.value).padStart(2, "0");
-  const dd = String(selectedDay.value).padStart(2, "0");
+  const day = String(selectedDay.value).padStart(2, "0");
+  const month = String(currentMonth.value).padStart(2, "0");
 
-  saving.value = true;
-  try {
+  await execute(async () => {
     const created = await createBaseHoliday({
-      description: desc,
-      holiday_date: `${currentYear.value}-${mm}-${dd}`,
-      type: newType.value,
+      description,
+      holiday_date: `${currentYear.value}-${month}-${day}`,
+      type: form.type,
     });
 
     holidays.value.push({
-      id: created.id,
       day: selectedDay.value,
-      month: currentMonth.value,
-      year: currentYear.value,
       description: created.description,
+      id: created.id,
+      month: currentMonth.value,
       type: created.type,
+      year: currentYear.value,
     });
 
-    view.value = "calendar";
     selectedDay.value = null;
-  } catch {
-    $q.notify({ type: "negative", message: "Erro ao salvar feriado." });
-  } finally {
-    saving.value = false;
-  }
-}
-
-function openEdit(holiday) {
-  $q.dialog({
-    component: HolidayEditDialog,
-    componentProps: { holiday },
-  }).onOk(({ action, holiday: updated, id }) => {
-    if (action === "update") {
-      const idx = holidays.value.findIndex((h) => h.id === updated.id);
-      if (idx !== -1) holidays.value[idx] = updated;
-    } else if (action === "delete") {
-      holidays.value = holidays.value.filter((h) => h.id !== id);
-    }
+    view.value = "calendar";
   });
-}
+};
 
-function onClose() {
-  onDialogOK(holidays.value);
-}
+const typeLabel = (type) => type === "facultativo" ? "Ponto Facultativo" : "Feriado";
 </script>
 
 <style scoped>
@@ -404,4 +519,4 @@ function onClose() {
 .cal-cell--holiday {
   cursor: default;
 }
-</style>
+</style>

+ 92 - 44
src/pages/financial/AsaasIntegrationPage.vue

@@ -3,29 +3,48 @@
     <DefaultHeaderPage title="Integração Asaas" />
 
     <div class="q-pa-md">
-      <q-card flat bordered class="q-pa-md" style="max-width: 720px">
+      <q-card
+        bordered
+        class="q-pa-md"
+        flat
+        style="max-width: 720px"
+      >
         <div class="row items-center no-wrap q-mb-xs">
-          <q-icon name="mdi-cash-multiple" size="sm" color="primary" class="q-mr-sm" />
-          <div class="text-subtitle1 text-weight-medium">Asaas — Conta da Franqueadora</div>
+          <q-icon
+            class="q-mr-sm"
+            color="primary"
+            name="mdi-cash-multiple"
+            size="sm"
+          />
+
+          <div class="text-subtitle1 text-weight-medium">
+            Asaas — Conta da Franqueadora
+          </div>
+
           <q-space />
+
           <q-badge
-            :color="account.asaas_configured ? 'positive' : 'grey-6'"
-            :label="account.asaas_configured ? 'Configurada' : 'Não configurada'"
             class="q-px-sm q-py-xs"
+            :color="account.asaas_configured ? 'positive' : 'grey-6'"
+            :label="
+              account.asaas_configured ? 'Configurada' : 'Não configurada'
+            "
           />
         </div>
 
         <div class="text-body2 text-grey-7 q-mb-md">
-          Informe aqui a chave da API da conta Asaas da franqueadora. Ela é usada para
-          emitir as cobranças do TBR das unidades. Você pode trocá-la a qualquer momento —
-          sem depender do suporte. Enquanto não houver chave, o sistema usa a configuração
-          padrão do ambiente.
+          Informe aqui a chave da API da conta Asaas da franqueadora. Ela é
+          usada para emitir as cobranças do TBR das unidades. Você pode trocá-la
+          a qualquer momento — sem depender do suporte. Enquanto não houver
+          chave, o sistema usa a configuração padrão do ambiente.
         </div>
 
-        <DefaultForm ref="formRef">
+        <DefaultForm ref="formRef" @submit="onSave">
           <DefaultPasswordInput
-            v-model="apiKey"
+            v-model="form.asaas_api_key"
             v-model:error="validationErrors.asaas_api_key"
+            autocomplete="off"
+            outlined
             :error-message="validationErrors.asaas_api_key"
             :label="
               account.asaas_configured
@@ -33,26 +52,32 @@
                 : 'Chave da API do Asaas'
             "
             :placeholder="account.asaas_api_key_masked || ''"
-            outlined
-            autocomplete="off"
+            :rules="[inputRules.max(500)]"
           />
 
-          <div class="row justify-end items-center q-mt-md" style="gap: 12px">
+          <div
+            class="row justify-end items-center q-mt-md"
+            style="gap: 12px"
+          >
             <q-btn
               v-if="canEdit && account.asaas_configured"
-              flat
               color="negative"
+              flat
               label="Remover chave"
+              no-caps
               :disable="loading"
               @click="onRemove"
             />
+
             <q-btn
               v-if="canEdit"
               color="primary"
               label="Salvar"
-              :disable="!apiKey"
+              no-caps
+              type="submit"
+              unelevated
+              :disable="!form.asaas_api_key"
               :loading="loading"
-              @click="onSave"
             />
           </div>
         </DefaultForm>
@@ -62,55 +87,78 @@
 </template>
 
 <script setup>
-import { onMounted, ref } from "vue";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
 import {
   getCompanyPaymentAccount,
   saveCompanyPaymentAccount,
 } from "src/api/company_payment_account";
 
+import { onMounted, ref } from "vue";
+import { permissionStore } from "src/stores/permission";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
-import { permissionStore } from "src/stores/permission";
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm({
+  asaas_api_key: "",
+});
 
 const formRef = ref(null);
-const canEdit = permissionStore().getAccess("franchisor_financial", "edit");
-const apiKey = ref("");
 
-const defaultAccount = () => ({
-  asaas_configured: false,
+const { loading, validationErrors, execute } = useSubmitHandler({
+  formRef,
+  scrollFn: scrollToComponent,
+});
+
+const account = ref({
   asaas_api_key_masked: null,
+  asaas_configured: false,
   status: "pending",
 });
 
-const account = ref(defaultAccount());
+const canEdit = permissionStore().getAccess("franchisor_financial", "edit");
 
-const { loading, validationErrors, execute } = useSubmitHandler({ formRef });
+const defaultAccount = () => ({
+  asaas_api_key_masked: null,
+  asaas_configured: false,
+  status: "pending",
+});
 
-async function fetchData() {
+const fetchData = async () => {
   try {
     const data = await getCompanyPaymentAccount();
+
     account.value = data ?? defaultAccount();
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   }
-}
+};
 
-async function persist(value) {
-  await execute(async () => {
-    account.value = await saveCompanyPaymentAccount({ asaas_api_key: value });
-    apiKey.value = "";
-  });
-}
+const onRemove = () => {
+  persist("");
+};
 
-function onSave() {
-  if (!apiKey.value) return;
-  persist(apiKey.value);
-}
+const onSave = () => {
+  if (!form.asaas_api_key) return;
 
-function onRemove() {
-  persist("");
-}
+  persist(form.asaas_api_key);
+};
+
+const persist = async (value) => {
+  await execute(async () => {
+    account.value = await saveCompanyPaymentAccount(
+      value === "" ? { asaas_api_key: "" } : getUpdatedFields.value,
+    );
+
+    form.asaas_api_key = "";
+  });
+};
 
 onMounted(fetchData);
-</script>
+</script>

+ 380 - 170
src/pages/kanban/components/AddEditKanbanDialog.vue

@@ -23,117 +23,141 @@
             overflow: hidden;
           "
           @submit="onOKClick"
+          @validation-error="handleValidationError"
         >
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
               <CustomTabComponent
                 v-if="card?.id"
                 v-model:active-tab="currentTab"
+                class="col-12 q-mb-md"
                 :tabs="tabs"
-                class="q-mb-md"
               />
 
-              <!-- Tab: Atividade -->
-              <div v-show="currentTab === 'atividade'">
-                <div class="row q-col-gutter-sm">
-                  <!-- 1. Título -->
+              <div
+                v-show="currentTab === 'atividade'"
+                class="col-12"
+              >
+                <div class="row q-col-gutter-md">
                   <DefaultInput
                     v-model="form.title"
-                    label="Título da Tarefa"
+                    v-model:error="validationErrors.title"
                     class="col-12"
-                    :rules="[(val) => !!val || 'Campo obrigatório']"
+                    label="Título da Tarefa"
+                    :error-message="validationErrors.title"
+                    :rules="[inputRules.required, maxLengthRule(255)]"
                   />
 
-                  <!-- 2. Destino (primeiro campo de configuração) -->
                   <DefaultSelect
                     v-model="form.scope"
-                    label="Destino"
-                    :options="scopeOptions"
+                    v-model:error="validationErrors.scope"
+                    class="col-12 col-sm-6"
                     emit-value
+                    label="Destino"
                     map-options
-                    class="col-6"
-                    :rules="[(val) => !!val || 'Campo obrigatório']"
+                    :error-message="validationErrors.scope"
+                    :options="scopeOptions"
+                    :rules="[inputRules.required]"
                   />
 
-                  <!-- Prazo ao lado do Destino -->
                   <DefaultInputDatePicker
                     v-model="form.due_date_display"
                     v-model:untreated-date="form.due_date"
+                    v-model:error="validationErrors.due_date"
+                    class="col-12 col-sm-6"
                     label="Prazo de Entrega"
-                    class="col-6"
+                    :error-message="validationErrors.due_date"
+                    :rules="[dateRule]"
                   />
 
-                  <!-- Unidade específica — só aparece quando scope = specific -->
                   <UnitSelect
                     v-if="form.scope === 'specific'"
                     v-model="selectedUnit"
-                    class="col-6"
+                    v-model:error="validationErrors.target_unit_id"
+                    class="col-12 col-sm-6"
+                    :error-message="validationErrors.target_unit_id"
                   />
 
-                  <!-- 3. Prioridade + Fase -->
                   <DefaultSelect
                     v-model="form.priority"
-                    label="Prioridade"
-                    :options="priorityOptions"
+                    v-model:error="validationErrors.priority"
+                    class="col-12 col-sm-6"
                     emit-value
+                    label="Prioridade"
                     map-options
-                    class="col-6"
-                    :rules="[(val) => !!val || 'Campo obrigatório']"
+                    :error-message="validationErrors.priority"
+                    :options="priorityOptions"
+                    :rules="[inputRules.required]"
                   />
 
                   <DefaultSelect
                     v-model="form.phase"
-                    label="Fase"
-                    :options="phaseOptions"
+                    v-model:error="validationErrors.phase"
+                    class="col-12 col-sm-6"
                     emit-value
+                    label="Fase"
                     map-options
-                    class="col-6"
-                    :rules="[(val) => !!val || 'Campo obrigatório']"
+                    :error-message="validationErrors.phase"
+                    :options="phaseOptions"
+                    :rules="[inputRules.required]"
                   />
 
-                  <!-- 4. Setor -->
                   <DefaultInput
                     v-model="form.sector"
+                    v-model:error="validationErrors.sector"
+                    class="col-12 col-sm-6"
                     label="Setor"
-                    class="col-6"
+                    :error-message="validationErrors.sector"
+                    :rules="[maxLengthRule(255)]"
                   />
 
-                  <!-- 5. Descrição -->
                   <DefaultInput
                     v-model="form.description"
+                    v-model:error="validationErrors.description"
+                    class="col-12"
                     label="Descrição"
                     type="textarea"
-                    class="col-12"
+                    :error-message="validationErrors.description"
                   />
                 </div>
               </div>
 
-              <!-- Tab: Comentários -->
-              <div v-show="currentTab === 'comentarios'">
+              <div
+                v-show="currentTab === 'comentarios'"
+                class="col-12"
+              >
                 <div class="flex justify-end q-mb-sm">
                   <q-btn
                     v-if="canAdd"
                     color="primary"
                     icon="mdi-plus"
-                    unelevated
                     style="width: 40px; height: 40px"
+                    unelevated
                     @click="onAddComment"
                   />
                 </div>
-                <div style="display: flex; flex-direction: column; gap: 8px">
+
+                <div
+                  style="
+                    display: flex;
+                    flex-direction: column;
+                    gap: 8px;
+                  "
+                >
                   <template v-if="replies.length">
                     <KanbanCommentCard
                       v-for="reply in replies"
                       :key="reply.id"
-                      :reply="reply.reply"
+                      :can-delete="canDelete"
+                      :can-edit="canEdit"
                       :created-at="reply.created_at"
+                      :reply="reply.reply"
                       :user-name="reply.user_name"
-                      :can-edit="canEdit"
-                      :can-delete="canDelete"
-                      @edit="onEditComment(reply)"
                       @delete="onDeleteComment(reply)"
+                      @edit="onEditComment(reply)"
                     />
                   </template>
+
                   <div
                     v-else
                     class="flex flex-center full-height text-grey-5 text-body2"
@@ -143,39 +167,50 @@
                 </div>
               </div>
 
-              <!-- Tab: Mídias -->
-              <div v-show="currentTab === 'midias'">
+              <div
+                v-show="currentTab === 'midias'"
+                class="col-12"
+              >
                 <div class="flex justify-end q-mb-sm">
                   <q-btn
                     v-if="canAdd"
                     color="primary"
                     icon="mdi-upload-outline"
-                    unelevated
                     style="width: 40px; height: 40px"
+                    unelevated
                     :loading="uploadingMedia"
                     @click="triggerFileInput"
                   />
+
                   <input
                     ref="fileInputRef"
-                    type="file"
                     style="display: none"
+                    type="file"
                     @change="onFileSelected"
                   />
                 </div>
-                <div style="display: flex; flex-direction: column; gap: 8px">
+
+                <div
+                  style="
+                    display: flex;
+                    flex-direction: column;
+                    gap: 8px;
+                  "
+                >
                   <template v-if="medias.length">
                     <KanbanMediaCard
                       v-for="media in medias"
                       :key="media.id"
+                      :can-delete="canDelete"
+                      :created-at="media.created_at"
                       :file-name="media.file_name"
                       :file-url="media.file_url"
                       :mime-type="media.mime_type"
-                      :created-at="media.created_at"
                       :user-name="media.user_name"
-                      :can-delete="canDelete"
                       @delete="onDeleteMedia(media)"
                     />
                   </template>
+
                   <div
                     v-else
                     class="flex flex-center full-height text-grey-5 text-body2"
@@ -187,22 +222,28 @@
             </q-card-section>
           </q-scroll-area>
 
+          <q-separator />
+
           <q-card-actions
             align="right"
-            class="q-px-md q-pb-md"
+            class="q-pa-md q-gutter-sm"
             style="flex-shrink: 0"
           >
             <q-btn
-              outline
-              color="primary"
+              color="negative"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
               v-if="card?.id ? canEdit : canAdd"
               color="primary"
               label="Salvar"
+              no-caps
               type="submit"
+              unelevated
               :loading="loading"
             />
           </q-card-actions>
@@ -213,70 +254,153 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
-import { useDialogPluginComponent, useQuasar } from "quasar";
-
-import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import UnitSelect from "src/components/selects/UnitSelect.vue";
-import KanbanCommentCard from "./KanbanCommentCard.vue";
-import KanbanMediaCard from "./KanbanMediaCard.vue";
+import {
+  createKanban,
+  updateKanban,
+} from "src/api/kanban";
 
-import { createKanban, updateKanban } from "src/api/kanban";
 import {
-  getKanbanReplies,
   createKanbanReply,
-  updateKanbanReply,
   deleteKanbanReply,
+  getKanbanReplies,
+  updateKanbanReply,
 } from "src/api/kanban_reply";
+
 import {
+  deleteKanbanMedia,
   getKanbanMedias,
   uploadKanbanMedia,
-  deleteKanbanMedia,
 } from "src/api/kanban_media";
+
+import { computed, nextTick, onMounted, ref, watch } from "vue";
 import { permissionStore } from "src/stores/permission";
 
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import KanbanCommentCard from "./KanbanCommentCard.vue";
+import KanbanMediaCard from "./KanbanMediaCard.vue";
+import UnitSelect from "src/components/selects/UnitSelect.vue";
+
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { card, initialPhase } = defineProps({
-  card: { type: Object, default: null },
-  initialPhase: { type: String, default: "a_fazer" },
+  card: {
+    default: null,
+    type: Object,
+  },
+  initialPhase: {
+    default: "a_fazer",
+    type: String,
+  },
 });
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
 const permissions = permissionStore();
-const canAdd = permissions.getAccess("franchisor_activities", "add");
-const canEdit = permissions.getAccess("franchisor_activities", "edit");
-const canDelete = permissions.getAccess("franchisor_activities", "delete");
 
+const fileInputRef = ref(null);
 const formRef = ref(null);
-const loading = ref(false);
+const scrollAreaRef = ref(null);
+
+const formatDisplayDate = (rawDate) => {
+  if (!rawDate) return null;
+
+  const [year, month, day] = rawDate.split("-");
+
+  return `${day}/${month}/${year}`;
+};
+
+const { form, getUpdatedFields } = useForm(
+  {
+    description: card?.description ?? null,
+    due_date: card?.due_date ?? null,
+    due_date_display: formatDisplayDate(card?.due_date),
+    phase: card?.phase ?? initialPhase,
+    priority: card?.priority ?? "normal",
+    responsible_user_id: card?.responsible_user_id ?? null,
+    scope: card?.scope ?? "internal",
+    sector: card?.sector ?? null,
+    target_unit_id: card?.target_unit_id ?? null,
+    title: card?.title ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const scrollToActivityComponent = async (
+  component,
+  containerRef,
+  options,
+) => {
+  currentTab.value = "atividade";
+
+  await nextTick();
+
+  return scrollToComponent(component, containerRef, options);
+};
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => onDialogOK(true),
+  scrollFn: scrollToActivityComponent,
+});
+
+const selectedUnit = ref(
+  card?.target_unit_id
+    ? {
+        value: card.target_unit_id,
+      }
+    : null,
+);
+
 const currentTab = ref("atividade");
-const replies = ref([]);
 const medias = ref([]);
+const replies = ref([]);
 const uploadingMedia = ref(false);
-const fileInputRef = ref(null);
-const selectedUnit = ref(
-  card?.target_unit_id ? { value: card.target_unit_id } : null,
-);
 
 const tabs = computed(() => [
-  { name: "atividade", label: "Atividade" },
-  { name: "comentarios", label: "Comentários" },
-  { name: "midias", label: "Mídias" },
+  {
+    label: "Atividade",
+    name: "atividade",
+  },
+  {
+    label: "Comentários",
+    name: "comentarios",
+  },
+  {
+    label: "Mídias",
+    name: "midias",
+  },
 ]);
 
-const priorityOptions = [
-  { label: "Alta", value: "alta" },
-  { label: "Normal", value: "normal" },
-  { label: "Baixa", value: "baixa" },
-];
+const canAdd = permissions.getAccess("franchisor_activities", "add");
+const canDelete = permissions.getAccess("franchisor_activities", "delete");
+const canEdit = permissions.getAccess("franchisor_activities", "edit");
 
 const phaseOptions = [
   { label: "A Fazer", value: "a_fazer" },
@@ -286,138 +410,224 @@ const phaseOptions = [
   { label: "Demandas Especiais", value: "demandas_especiais" },
 ];
 
+const priorityOptions = [
+  { label: "Alta", value: "alta" },
+  { label: "Normal", value: "normal" },
+  { label: "Baixa", value: "baixa" },
+];
+
 const scopeOptions = [
   { label: "Interno", value: "internal" },
   { label: "Todas as Unidades", value: "all" },
   { label: "Unidade Específica", value: "specific" },
 ];
 
-const formatDisplayDate = (rawDate) => {
-  if (!rawDate) return null;
-  const [y, m, d] = rawDate.split("-");
-  return `${d}/${m}/${y}`;
+const buildPayload = () => ({
+  description: form.description || null,
+  due_date: form.due_date || null,
+  phase: form.phase,
+  priority: form.priority,
+  responsible_user_id: form.responsible_user_id,
+  scope: form.scope,
+  sector: form.sector || null,
+  target_unit_id: form.scope === "specific" ? form.target_unit_id : null,
+  title: form.title,
+});
+
+const dateRule = (value) => {
+  if (!value) return true;
+
+  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
+
+  if (!match) return "Informe uma data válida";
+
+  const [, year, month, day] = match.map(Number);
+  const date = new Date(year, month - 1, day);
+
+  return (
+    (date.getFullYear() === year &&
+      date.getMonth() === month - 1 &&
+      date.getDate() === day) ||
+    "Informe uma data válida"
+  );
 };
 
-const form = ref({
-  title: card?.title ?? null,
-  priority: card?.priority ?? "normal",
-  phase: card?.phase ?? initialPhase,
-  scope: card?.scope ?? "internal",
-  sector: card?.sector ?? null,
-  description: card?.description ?? null,
-  due_date: card?.due_date ?? null,
-  due_date_display: formatDisplayDate(card?.due_date),
-});
+const handleValidationError = async (component) => {
+  currentTab.value = "atividade";
 
-const buildPayload = () => ({
-  title: form.value.title,
-  priority: form.value.priority,
-  phase: form.value.phase,
-  scope: form.value.scope,
-  sector: form.value.sector || null,
-  description: form.value.description || null,
-  due_date: form.value.due_date || null,
-  responsible_user_id: null, // Matriz never assigns a responsible
-  target_unit_id:
-    form.value.scope === "specific"
-      ? (selectedUnit.value?.value ?? null)
-      : null,
-});
+  await nextTick();
 
-const loadReplies = async () => {
-  if (!card?.id) return;
-  replies.value = await getKanbanReplies(card.id);
+  if (component) {
+    component.focus?.();
+    scrollToComponent(component, scrollAreaRef);
+  }
 };
 
 const loadMedias = async () => {
   if (!card?.id) return;
+
   medias.value = await getKanbanMedias(card.id);
 };
 
-const triggerFileInput = () => {
-  fileInputRef.value?.click();
-};
+const loadReplies = async () => {
+  if (!card?.id) return;
 
-const onFileSelected = async (event) => {
-  const file = event.target.files?.[0];
-  if (!file) return;
-  uploadingMedia.value = true;
-  try {
-    await uploadKanbanMedia(card.id, file);
-    await loadMedias();
-  } finally {
-    uploadingMedia.value = false;
-    event.target.value = "";
-  }
+  replies.value = await getKanbanReplies(card.id);
 };
 
-const onDeleteMedia = (media) => {
-  $q.dialog({
-    title: "Excluir Mídia",
-    message: `Deseja excluir "${media.file_name}"?`,
-    cancel: { outline: true, color: "primary", label: "Cancelar" },
-    ok: { color: "negative", label: "Excluir" },
-  }).onOk(async () => {
-    await deleteKanbanMedia(card.id, media.id);
-    loadMedias();
-  });
-};
+const maxLengthRule = (maximum) => (value) =>
+  !value ||
+  String(value).length <= maximum ||
+  `O campo deve ter no máximo ${maximum} caracteres`;
 
 const onAddComment = () => {
   $q.dialog({
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
+    ok: {
+      color: "primary",
+      label: "Salvar",
+    },
+    prompt: {
+      label: "Comentário *",
+      model: "",
+      type: "textarea",
+    },
     title: "Adicionar Comentário",
-    prompt: { model: "", type: "textarea", label: "Comentário" },
-    ok: { label: "Salvar", color: "primary" },
-    cancel: { label: "Cancelar", color: "primary", outline: true },
   }).onOk(async (text) => {
     if (!text?.trim()) return;
-    await createKanbanReply(card.id, { reply: text.trim() });
+
+    await createKanbanReply(card.id, {
+      reply: text.trim(),
+    });
+
     loadReplies();
   });
 };
 
-const onEditComment = (reply) => {
+const onDeleteComment = (reply) => {
   $q.dialog({
-    title: "Editar Comentário",
-    prompt: { model: reply.reply, type: "textarea", label: "Comentário" },
-    ok: { label: "Salvar", color: "primary" },
-    cancel: { label: "Cancelar", color: "primary", outline: true },
-  }).onOk(async (text) => {
-    if (!text?.trim()) return;
-    await updateKanbanReply(card.id, reply.id, { reply: text.trim() });
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
+    message: "Tem certeza que deseja excluir este comentário?",
+    ok: {
+      color: "negative",
+      label: "Excluir",
+    },
+    title: "Excluir Comentário",
+  }).onOk(async () => {
+    await deleteKanbanReply(card.id, reply.id);
+
     loadReplies();
   });
 };
 
-const onDeleteComment = (reply) => {
+const onDeleteMedia = (media) => {
   $q.dialog({
-    title: "Excluir Comentário",
-    message: "Tem certeza que deseja excluir este comentário?",
-    cancel: { outline: true, color: "primary", label: "Cancelar" },
-    ok: { color: "negative", label: "Excluir" },
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
+    message: `Deseja excluir "${media.file_name}"?`,
+    ok: {
+      color: "negative",
+      label: "Excluir",
+    },
+    title: "Excluir Mídia",
   }).onOk(async () => {
-    await deleteKanbanReply(card.id, reply.id);
+    await deleteKanbanMedia(card.id, media.id);
+
+    loadMedias();
+  });
+};
+
+const onEditComment = (reply) => {
+  $q.dialog({
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
+    ok: {
+      color: "primary",
+      label: "Salvar",
+    },
+    prompt: {
+      label: "Comentário",
+      model: reply.reply,
+      type: "textarea",
+    },
+    title: "Editar Comentário",
+  }).onOk(async (text) => {
+    if (!text?.trim()) return;
+
+    await updateKanbanReply(card.id, reply.id, {
+      reply: text.trim(),
+    });
+
     loadReplies();
   });
 };
 
-const onOKClick = async () => {
-  loading.value = true;
+const onFileSelected = async (event) => {
+  const file = event.target.files?.[0];
+
+  if (!file) return;
+
+  uploadingMedia.value = true;
+
   try {
-    const payload = buildPayload();
-    if (card?.id) {
-      await updateKanban(card.id, payload);
-    } else {
-      await createKanban(payload);
-    }
-    onDialogOK(true);
+    await uploadKanbanMedia(card.id, file);
+    await loadMedias();
   } finally {
-    loading.value = false;
+    event.target.value = "";
+    uploadingMedia.value = false;
   }
 };
 
+const triggerFileInput = () => {
+  fileInputRef.value?.click();
+};
+
+const onOKClick = async () => {
+  await execute(() => {
+    if (card?.id) {
+      const payload = {
+        ...getUpdatedFields.value,
+      };
+
+      delete payload.due_date_display;
+
+      return updateKanban(card.id, payload);
+    }
+
+    return createKanban(buildPayload());
+  });
+};
+
+watch(selectedUnit, (unit) => {
+  form.target_unit_id = unit?.value ?? null;
+});
+
+watch(
+  () => form.scope,
+  (scope) => {
+    if (scope !== "specific") {
+      selectedUnit.value = null;
+      form.target_unit_id = null;
+    }
+  },
+);
+
 onMounted(() => {
-  loadReplies();
   loadMedias();
+  loadReplies();
 });
-</script>
+</script>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 475 - 362
src/pages/packages/components/AddEditPackageDialog.vue


+ 76 - 43
src/pages/permissions/components/AddEditPermissionGroupDialog.vue

@@ -4,15 +4,16 @@
       <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
 
       <DefaultForm ref="formRef" @submit="onSave">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="column q-gutter-md">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
             <DefaultInput
               v-model="form.label"
               v-model:error="validationErrors.label"
-              :error-message="validationErrors.label"
+              autofocus
+              class="col-12"
               label="Nome"
               outlined
-              autofocus
+              :error-message="validationErrors.label"
               :rules="[inputRules.required]"
               @update:model-value="onLabelChange"
             />
@@ -20,27 +21,34 @@
             <DefaultInput
               v-model="form.slug"
               v-model:error="validationErrors.slug"
-              :error-message="validationErrors.slug"
+              class="col-12"
+              hint="Identificador único do grupo. Ex.: FINANCEIRO"
               label="Código"
               outlined
-              hint="Identificador único do grupo. Ex.: FINANCEIRO"
+              :error-message="validationErrors.slug"
               :rules="[inputRules.required, slugRule]"
               @update:model-value="onSlugTouched"
             />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right">
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
+            color="negative"
             label="Cancelar"
-            color="primary"
+            no-caps
             outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            label="Salvar"
             color="primary"
+            label="Salvar"
+            no-caps
             type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -51,75 +59,100 @@
 
 <script setup>
 import { computed, ref, useTemplateRef } from "vue";
+
+import {
+  createUserType,
+  updateUserType,
+} from "src/api/user_type";
+
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createUserType, updateUserType } from "src/api/user_type";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
 const { group } = defineProps({
   group: {
-    type: Object,
     default: null,
+    type: Object,
   },
 });
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const { inputRules } = useInputRules();
+
 const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
-const isEdit = computed(() => !!group);
+const { scrollToComponent } = useScroll();
 
-const dialogTitle = computed(() =>
-  isEdit.value ? "Editar Grupo de Permissões" : "Novo Grupo de Permissões",
+const { form, getUpdatedFields } = useForm(
+  {
+    label: group?.label ?? "",
+    slug: group?.slug ?? "",
+  },
+  { containerRef: scrollAreaRef },
 );
 
-const form = ref({
-  label: group?.label ?? "",
-  slug: group?.slug ?? "",
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => onDialogOK(),
+  scrollFn: scrollToComponent,
 });
 
-// Ao editar, o código já existe e não deve ser re-derivado do nome.
 const slugTouched = ref(isEdit.value);
 
-const toSlug = (value) =>
-  (value ?? "")
-    .normalize("NFD")
-    .replace(/\p{Diacritic}/gu, "")
-    .toUpperCase()
-    .replace(/[^A-Z0-9]+/g, "_")
-    .replace(/^_+|_+$/g, "");
+const isEdit = computed(() => !!group);
 
-const slugRule = (value) =>
-  /^[A-Z0-9_]+$/.test(value ?? "") ||
-  "Use apenas letras maiúsculas, números e underline.";
+const dialogTitle = computed(() =>
+  isEdit.value ? "Editar Grupo de Permissões" : "Novo Grupo de Permissões",
+);
 
 const onLabelChange = (value) => {
   if (!slugTouched.value) {
-    form.value.slug = toSlug(value);
+    form.slug = toSlug(value);
   }
 };
 
-const onSlugTouched = (value) => {
-  slugTouched.value = true;
-  form.value.slug = toSlug(value);
-};
-
-const { loading, validationErrors, execute } = useSubmitHandler({
-  formRef,
-  onSuccess: () => onDialogOK(),
-});
-
 const onSave = async () => {
-  const payload = { label: form.value.label, slug: form.value.slug };
+  const payload = {
+    label: form.label,
+    slug: form.slug,
+  };
 
   await execute(() =>
-    isEdit.value ? updateUserType(group.id, payload) : createUserType(payload),
+    isEdit.value
+      ? updateUserType(group.id, getUpdatedFields.value)
+      : createUserType(payload),
   );
 };
-</script>
+
+const onSlugTouched = (value) => {
+  slugTouched.value = true;
+  form.slug = toSlug(value);
+};
+
+const slugRule = (value) =>
+  /^[A-Z0-9_]+$/.test(value ?? "") ||
+  "Use apenas letras maiúsculas, números e underline.";
+
+const toSlug = (value) =>
+  (value ?? "")
+    .normalize("NFD")
+    .replace(/\p{Diacritic}/gu, "")
+    .toUpperCase()
+    .replace(/[^A-Z0-9]+/g, "_")
+    .replace(/^_+|_+$/g, "");
+</script>

+ 75 - 47
src/pages/products/ProductsPage.vue

@@ -1,19 +1,23 @@
 <template>
-  <div>
+  <div class="tab-page-layout">
     <DefaultHeaderPage title="Produtos" :show-filter-icon="false" />
 
-    <div class="q-px-sm">
+    <div class="tab-page-body q-px-sm">
       <CustomTabComponent
         v-model:active-tab="currentTab"
-        :tabs="tabs"
         class="q-mb-sm"
+        :tabs="tabs"
       />
-      <div class="row items-center justify-between q-my-md" style="gap: 12px">
+
+      <div
+        class="row items-center justify-between q-my-md"
+        style="gap: 12px"
+      >
         <q-input
           v-model="search"
+          bg-color="white"
           dense
           outlined
-          bg-color="white"
           placeholder="Buscar produto..."
           style="width: 480px"
         >
@@ -21,100 +25,124 @@
             <q-icon name="mdi-magnify" />
           </template>
         </q-input>
+
         <div class="row items-center" style="gap: 8px">
           <q-pagination
             v-model="currentPage"
-            :max="totalPages"
-            :max-pages="5"
+            active-color="primary"
             boundary-numbers
-            direction-links
             color="primary"
-            active-color="primary"
+            direction-links
+            :max="totalPages"
+            :max-pages="5"
           />
+
           <q-btn
             v-if="currentTab === 'products' && canAdd"
-            color="primary-2"
+            color="primary"
+            icon="mdi-plus"
             style="height: 40px; width: 40px; min-width: 40px"
+            unelevated
             @click="handleAdd"
-          >
-            <q-icon name="mdi-plus" color="white" size="20px" />
-          </q-btn>
+          />
 
           <q-btn
             v-if="currentTab === 'orders' && canAdd"
-            label="Importar"
-            color="primary-2"
+            color="primary"
             icon="mdi-file-import-outline"
+            label="Importar"
+            no-caps
             style="height: 40px"
+            unelevated
             @click="handleImport"
           />
         </div>
       </div>
 
-      <div v-show="currentTab === 'products'">
-        <ProductsTab ref="productsTabRef" />
-      </div>
+      <q-scroll-area class="tab-page-scroll-area">
+        <div v-show="currentTab === 'products'">
+          <ProductsTab ref="productsTabRef" />
+        </div>
 
-      <div v-show="currentTab === 'stock'">
-        <StockTab ref="stockTabRef" />
-      </div>
+        <div v-show="currentTab === 'stock'">
+          <StockTab ref="stockTabRef" />
+        </div>
 
-      <div v-show="currentTab === 'orders'">
-        <OrdersTab ref="ordersTabRef" />
-      </div>
+        <div v-show="currentTab === 'orders'">
+          <OrdersTab ref="ordersTabRef" />
+        </div>
+      </q-scroll-area>
     </div>
   </div>
 </template>
 
 <script setup>
 import { defineAsyncComponent, ref, shallowRef, watch } from "vue";
-import { useQuasar } from "quasar";
 import { permissionStore } from "src/stores/permission";
+import { useQuasar } from "quasar";
 
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
 import AddEditProductDialog from "./components/AddEditProductDialog.vue";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import ImportOrderDialog from "./components/ImportOrderDialog.vue";
 
-const ProductsTab = defineAsyncComponent(() => import("./tabs/ProductsTab.vue"));
-const StockTab = defineAsyncComponent(() => import("./tabs/StockTab.vue"));
-const OrdersTab = defineAsyncComponent(() => import("./tabs/OrdersTab.vue"));
+const $q = useQuasar();
+const permissions = permissionStore();
 
+const currentPage = ref(1);
 const currentTab = ref("products");
-const permissions = permissionStore();
-const canAdd = permissions.getAccess("franchisor_products", "add");
 const search = ref("");
-const currentPage = ref(1);
 const totalPages = ref(1);
+
+const ordersTabRef = shallowRef(null);
 const productsTabRef = shallowRef(null);
 const stockTabRef = shallowRef(null);
-const ordersTabRef = shallowRef(null);
 
-watch(currentTab, (tab) => {
-  if (tab === "products") productsTabRef.value?.fetchProducts();
-  if (tab === "stock")    stockTabRef.value?.refresh();
-  if (tab === "orders")   ordersTabRef.value?.refresh();
-});
+const canAdd = permissions.getAccess("franchisor_products", "add");
+
+const OrdersTab = defineAsyncComponent(() => import("./tabs/OrdersTab.vue"));
+
+const ProductsTab = defineAsyncComponent(
+  () => import("./tabs/ProductsTab.vue"),
+);
+
+const StockTab = defineAsyncComponent(() => import("./tabs/StockTab.vue"));
 
 const tabs = [
-  { name: "products", label: "Produtos" },
-  { name: "stock", label: "Estoque" },
-  { name: "orders", label: "Pedidos" },
+  { label: "Produtos", name: "products" },
+  { label: "Estoque", name: "stock" },
+  { label: "Pedidos", name: "orders" },
 ];
 
-const $q = useQuasar();
-
 const handleAdd = () => {
-  $q.dialog({ component: AddEditProductDialog }).onOk(() => {
+  $q.dialog({
+    component: AddEditProductDialog,
+  }).onOk(() => {
     productsTabRef.value?.fetchProducts();
   });
 };
 
 const handleImport = () => {
-  $q.dialog({ component: ImportOrderDialog }).onOk(() => {
+  $q.dialog({
+    component: ImportOrderDialog,
+  }).onOk(() => {
     ordersTabRef.value?.refresh();
     productsTabRef.value?.fetchProducts();
     stockTabRef.value?.refresh();
   });
 };
-</script>
+
+watch(currentTab, (tab) => {
+  if (tab === "products") {
+    productsTabRef.value?.fetchProducts();
+  }
+
+  if (tab === "stock") {
+    stockTabRef.value?.refresh();
+  }
+
+  if (tab === "orders") {
+    ordersTabRef.value?.refresh();
+  }
+});
+</script>

+ 143 - 83
src/pages/products/components/AddEditProductDialog.vue

@@ -8,61 +8,78 @@
         />
 
         <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm">
-              <div class="text-h6 q-mb-sm">Produto</div>
-
-              <div class="row q-col-gutter-sm">
-                <DefaultInput
-                  v-model="form.name"
-                  label="Nome do Produto"
-                  class="col-12"
-                />
-
-                <DefaultInput
-                  v-model="form.description"
-                  label="Descrição"
-                  class="col-12"
-                  type="textarea"
-                  autogrow
-                />
-
-                <DefaultCurrencyInput
-                  v-model="form.unitPrice"
-                  label="Valor Unitário"
-                  class="col-4"
-                />
-
-                <DefaultInput
-                  v-model="form.quantity"
-                  label="Quantidade"
-                  class="col-4"
-                  type="number"
-                  min="0"
-                />
-
-                <DefaultInput
-                  :model-value="totalValueFormatted"
-                  label="Valor Total do Produto"
-                  class="col-4"
-                  disable
-                  readonly
-                />
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
+              <div class="col-12 text-h6">
+                Produto
               </div>
+
+              <DefaultInput
+                v-model="form.name"
+                v-model:error="validationErrors.name"
+                class="col-12"
+                label="Nome do Produto"
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.description"
+                v-model:error="validationErrors.description"
+                autogrow
+                class="col-12"
+                label="Descrição"
+                type="textarea"
+                :error-message="validationErrors.description"
+              />
+
+              <DefaultCurrencyInput
+                v-model="form.unitPrice"
+                v-model:error="validationErrors.price_sale"
+                class="col-4"
+                label="Valor Unitário"
+                :error-message="validationErrors.price_sale"
+                :rules="[inputRules.requiredNumber]"
+              />
+
+              <DefaultInput
+                v-model="form.quantity"
+                v-model:error="validationErrors.quantity"
+                class="col-4"
+                label="Quantidade"
+                min="0"
+                type="number"
+                :error-message="validationErrors.quantity"
+                :rules="[inputRules.requiredNumber, inputRules.minValue(0)]"
+              />
+
+              <DefaultInput
+                class="col-4"
+                disable
+                label="Valor Total do Produto"
+                readonly
+                :model-value="totalValueFormatted"
+              />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
             <q-btn
+              color="negative"
               label="Cancelar"
+              no-caps
               outline
-              color="primary-2"
               @click="onDialogCancel"
             />
+
             <q-btn
-              :label="isEdit ? 'Salvar Alterações' : 'Cadastrar Produto'"
-              color="primary-2"
+              color="primary"
+              no-caps
               type="submit"
+              unelevated
+              :label="isEdit ? 'Salvar Alterações' : 'Cadastrar Produto'"
               :loading="loading"
             />
           </q-card-actions>
@@ -73,68 +90,111 @@
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
-import { useDialogPluginComponent, useQuasar } from "quasar";
-import { createProduct, updateProduct } from "src/api/product";
+import { computed, ref } from "vue";
+
+import {
+  createProduct,
+  updateProduct,
+} from "src/api/product";
+
 import { formatToBRLCurrency } from "src/helpers/utils";
 
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 
 const props = defineProps({
   product: {
-    type: Object,
     default: null,
+    type: Object,
   },
 });
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const $q = useQuasar();
-
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
+const $q = useQuasar();
+const { inputRules } = useInputRules();
+
 const formRef = ref(null);
-const loading = ref(false);
+const scrollAreaRef = ref(null);
 
-const isEdit = computed(() => !!props.product);
+const { scrollToComponent } = useScroll();
 
-const form = ref({
-  name: props.product?.name ?? "",
-  description: props.product?.description ?? "",
-  unitPrice: props.product?.price_sale ?? 0,
-  quantity: props.product?.quantity ?? 0,
-});
-
-const totalValue = computed(() => form.value.unitPrice * form.value.quantity);
-const totalValueFormatted = computed(() =>
-  formatToBRLCurrency(totalValue.value),
+const { form, getUpdatedFields } = useForm(
+  {
+    description: props.product?.description ?? "",
+    name: props.product?.name ?? "",
+    quantity: props.product?.quantity ?? 0,
+    unitPrice: props.product?.price_sale ?? 0,
+  },
+  { containerRef: scrollAreaRef },
 );
 
-const onOKClick = async () => {
-  loading.value = true;
-  try {
-    const payload = {
-      name: form.value.name,
-      description: form.value.description,
-      price_sale: form.value.unitPrice,
-      quantity: Number(form.value.quantity),
-    };
-    const product = isEdit.value
-      ? await updateProduct(props.product.id, payload)
-      : await createProduct(payload);
-    onDialogOK(product);
-  } catch {
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onError: () => {
     $q.notify({
-      type: "negative",
       message: isEdit.value
         ? "Erro ao editar produto."
         : "Erro ao cadastrar produto.",
+      type: "negative",
     });
-  } finally {
-    loading.value = false;
-  }
+  },
+  onSuccess: (product) => onDialogOK(product),
+  scrollFn: scrollToComponent,
+});
+
+const isEdit = computed(() => !!props.product);
+
+const totalValue = computed(() => form.unitPrice * form.quantity);
+
+const totalValueFormatted = computed(() =>
+  formatToBRLCurrency(totalValue.value),
+);
+
+const onOKClick = async () => {
+  const payload = {
+    description: form.description,
+    name: form.name,
+    price_sale: form.unitPrice,
+    quantity: Number(form.quantity),
+  };
+
+  await execute(() => {
+    if (isEdit.value) {
+      const updated = {
+        ...getUpdatedFields.value,
+      };
+
+      if (updated.unitPrice !== undefined) {
+        updated.price_sale = updated.unitPrice;
+
+        delete updated.unitPrice;
+      }
+
+      return updateProduct(props.product.id, updated);
+    }
+
+    return createProduct(payload);
+  });
 };
-</script>
+</script>

+ 132 - 70
src/pages/products/components/AddProductDialog.vue

@@ -5,61 +5,79 @@
         <DefaultDialogHeader title="Novo Produto" @close="onDialogCancel" />
 
         <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm">
-              <div class="text-h6 q-mb-sm">Produto</div>
-
-              <div class="row q-col-gutter-sm">
-                <DefaultInput
-                  v-model="form.name"
-                  label="Nome do Produto"
-                  class="col-12"
-                />
-
-                <DefaultInput
-                  v-model="form.description"
-                  label="Descrição"
-                  class="col-12"
-                  type="textarea"
-                  autogrow
-                />
-
-                <DefaultCurrencyInput
-                  v-model="form.unitPrice"
-                  label="Valor Unitário"
-                  class="col-4"
-                />
-
-                <DefaultInput
-                  v-model="form.quantity"
-                  label="Quantidade"
-                  class="col-4"
-                  type="number"
-                  min="0"
-                />
-
-                <DefaultInput
-                  :model-value="totalValueFormatted"
-                  label="Valor Total do Produto"
-                  class="col-4"
-                  disable
-                  readonly
-                />
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
+              <div class="col-12 text-h6">
+                Produto
               </div>
+
+              <DefaultInput
+                v-model="form.name"
+                v-model:error="validationErrors.name"
+                class="col-12"
+                label="Nome do Produto"
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required, maxLengthRule(255)]"
+              />
+
+              <DefaultInput
+                v-model="form.description"
+                v-model:error="validationErrors.description"
+                autogrow
+                class="col-12"
+                label="Descrição"
+                type="textarea"
+                :error-message="validationErrors.description"
+                :rules="[maxLengthRule(2000)]"
+              />
+
+              <DefaultCurrencyInput
+                v-model="form.unitPrice"
+                v-model:error="validationErrors.price_sale"
+                class="col-4"
+                label="Valor Unitário"
+                :error-message="validationErrors.price_sale"
+                :rules="[requiredValueRule]"
+              />
+
+              <DefaultInput
+                v-model="form.quantity"
+                v-model:error="validationErrors.quantity"
+                class="col-4"
+                label="Quantidade"
+                min="0"
+                type="number"
+                :error-message="validationErrors.quantity"
+                :rules="[requiredValueRule, quantityRule]"
+              />
+
+              <DefaultInput
+                class="col-4"
+                disable
+                label="Valor Total do Produto"
+                readonly
+                :model-value="totalValueFormatted"
+              />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
             <q-btn
+              color="negative"
               label="Cancelar"
+              no-caps
               outline
-              color="primary-2"
               @click="onDialogCancel"
             />
+
             <q-btn
+              color="primary"
               label="Cadastrar Produto"
-              color="primary-2"
+              no-caps
               type="submit"
+              unelevated
               :loading="loading"
             />
           </q-card-actions>
@@ -70,50 +88,94 @@
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
-import { useDialogPluginComponent, useQuasar } from "quasar";
+import { computed, ref } from "vue";
 import { createProduct } from "src/api/product";
 import { formatToBRLCurrency } from "src/helpers/utils";
 
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const $q = useQuasar();
-
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
+const $q = useQuasar();
+const { inputRules } = useInputRules();
+
 const formRef = ref(null);
-const loading = ref(false);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    description: "",
+    name: "",
+    quantity: 0,
+    unitPrice: 0,
+  },
+  { containerRef: scrollAreaRef },
+);
 
-const form = ref({
-  name: "",
-  description: "",
-  unitPrice: 0,
-  quantity: 0,
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onError: () => {
+    $q.notify({
+      message: "Erro ao cadastrar produto.",
+      type: "negative",
+    });
+  },
+  onSuccess: (product) => onDialogOK(product),
+  scrollFn: scrollToComponent,
 });
 
-const totalValue = computed(() => form.value.unitPrice * form.value.quantity);
+const totalValue = computed(() => form.unitPrice * form.quantity);
+
 const totalValueFormatted = computed(() =>
   formatToBRLCurrency(totalValue.value),
 );
 
+const maxLengthRule = (maximum) => (value) =>
+  !value ||
+  String(value).length <= maximum ||
+  `O campo deve ter no máximo ${maximum} caracteres`;
+
+const quantityRule = (value) =>
+  (Number.isInteger(Number(value)) && Number(value) >= 0) ||
+  "A quantidade deve ser um número inteiro maior ou igual a 0";
+
+const requiredValueRule = (value) =>
+  (value !== null && value !== undefined && value !== "") ||
+  inputRules.required(null);
+
+requiredValueRule.$id = "required";
+
 const onOKClick = async () => {
-  loading.value = true;
-  try {
-    const product = await createProduct({
-      name: form.value.name,
-      description: form.value.description,
-      price_sale: form.value.unitPrice,
-    });
-    onDialogOK(product);
-  } catch {
-    $q.notify({ type: "negative", message: "Erro ao cadastrar produto." });
-  } finally {
-    loading.value = false;
-  }
+  await execute(() =>
+    createProduct({
+      description: form.description,
+      name: form.name,
+      price_sale: form.unitPrice,
+      quantity: Number(form.quantity),
+    }),
+  );
 };
-</script>
+</script>

+ 169 - 92
src/pages/products/components/ImportOrderDialog.vue

@@ -4,117 +4,194 @@
       <q-card class="overflow-hidden" style="width: 100%">
         <DefaultDialogHeader title="Importar Pedido" @close="onDialogCancel" />
 
-        <q-card-section class="q-pt-sm">
-          <div class="row items-center q-col-gutter-sm">
-            <div class="col">
-              <q-file
-                v-model="selectedFile"
-                outlined
-                dense
-                accept=".xlsx,.xls"
-                label="Pesquisar arquivo Excel..."
-                @update:model-value="importedRows = []"
-              >
-                <template #prepend>
-                  <q-icon name="mdi-file-excel-outline" color="secondary" />
-                </template>
-              </q-file>
-            </div>
-
-            <div class="col-auto">
-              <q-btn
-                label="Importar"
-                color="primary-2"
-                :loading="loading"
-                :disable="!selectedFile"
-                style="height: 40px"
-                @click="handleImport"
-              />
-            </div>
-          </div>
-
-          <template v-if="importedRows.length > 0">
-            <q-separator class="q-my-md" />
-
-            <q-table
-              flat
-              dense
-              :rows="importedRows"
-              :columns="columns"
-              row-key="name"
-              hide-bottom
-              :pagination="{ rowsPerPage: 0 }"
-            >
-              <template #body-cell-price_sale="{ row }">
-                <q-td>{{ formatToBRLCurrency(row.price_sale) }}</q-td>
-              </template>
-              <template #body-cell-subtotal="{ row }">
-                <q-td>{{ formatToBRLCurrency(row.price_sale * row.quantity) }}</q-td>
+        <DefaultForm ref="formRef" @submit="handleImport">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
+              <div class="col-12">
+                <div class="row items-center q-col-gutter-sm">
+                  <div class="col">
+                    <DefaultMediaPicker
+                      v-model="form.file"
+                      v-model:error="validationErrors.file"
+                      accept=".xlsx,.xls"
+                      dense
+                      label="Pesquisar arquivo Excel..."
+                      :label-outside="false"
+                      :rules="[inputRules.required]"
+                      @update:model-value="importedRows = []"
+                    />
+                  </div>
+
+                  <div class="col-auto">
+                    <q-btn
+                      color="primary"
+                      label="Importar"
+                      no-caps
+                      style="height: 40px"
+                      type="submit"
+                      unelevated
+                      :disable="!form.file"
+                      :loading="loading"
+                    />
+                  </div>
+                </div>
+              </div>
+
+              <template v-if="importedRows.length > 0">
+                <div class="col-12">
+                  <q-separator />
+                </div>
+
+                <div class="col-12">
+                  <q-table
+                    dense
+                    flat
+                    hide-bottom
+                    row-key="name"
+                    :columns="columns"
+                    :pagination="{ rowsPerPage: 0 }"
+                    :rows="importedRows"
+                  >
+                    <template #body-cell-price_sale="{ row }">
+                      <q-td>
+                        {{ formatToBRLCurrency(row.price_sale) }}
+                      </q-td>
+                    </template>
+
+                    <template #body-cell-subtotal="{ row }">
+                      <q-td>
+                        {{
+                          formatToBRLCurrency(row.price_sale * row.quantity)
+                        }}
+                      </q-td>
+                    </template>
+                  </q-table>
+                </div>
+
+                <div class="col-12 row justify-end">
+                  <span class="text-body2">
+                    Total:
+                    <strong>{{ formatToBRLCurrency(total) }}</strong>
+                  </span>
+                </div>
               </template>
-            </q-table>
-
-            <div class="row justify-end q-mt-sm q-pr-sm">
-              <span class="text-body2">
-                Total:
-                <strong>{{ formatToBRLCurrency(total) }}</strong>
-              </span>
-            </div>
-          </template>
-        </q-card-section>
-
-        <q-card-actions align="right" class="q-px-md q-pb-md">
-          <q-btn
-            label="Fechar"
-            outline
-            color="primary-2"
-            @click="importedRows.length > 0 ? onDialogOK() : onDialogCancel()"
-          />
-        </q-card-actions>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+            <q-btn
+              color="negative"
+              label="Fechar"
+              no-caps
+              outline
+              @click="importedRows.length > 0 ? onDialogOK() : onDialogCancel()"
+            />
+          </q-card-actions>
+        </DefaultForm>
       </q-card>
     </div>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
-import { useDialogPluginComponent, useQuasar } from "quasar";
-import { importProducts } from "src/api/product";
+import { computed, ref } from "vue";
 import { formatToBRLCurrency } from "src/helpers/utils";
+import { importProducts } from "src/api/product";
+
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultMediaPicker from "src/components/defaults/DefaultMediaPicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const $q = useQuasar();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const selectedFile = ref(null);
-const loading = ref(false);
-const importedRows = ref([]);
+const $q = useQuasar();
+const { inputRules } = useInputRules();
 
-const columns = [
-  { name: "name",      label: "Descrição", field: "name",      align: "left" },
-  { name: "quantity",  label: "Qtde",      field: "quantity",  align: "center" },
-  { name: "price_sale", label: "Preço",   field: "price_sale", align: "right" },
-  { name: "subtotal",  label: "Subtotal",  field: "subtotal",  align: "right" },
-];
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    file: null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onError: (error) => {
+    const message =
+      error?.response?.data?.message ?? "Erro ao importar arquivo.";
+
+    $q.notify({
+      message,
+      type: "negative",
+    });
+  },
+  scrollFn: scrollToComponent,
+});
+
+const importedRows = ref([]);
 
 const total = computed(() =>
-  importedRows.value.reduce((sum, r) => sum + r.price_sale * r.quantity, 0)
+  importedRows.value.reduce(
+    (sum, row) => sum + row.price_sale * row.quantity,
+    0,
+  ),
 );
 
+const columns = [
+  {
+    align: "left",
+    field: "name",
+    label: "Descrição",
+    name: "name",
+  },
+  {
+    align: "center",
+    field: "quantity",
+    label: "Qtde",
+    name: "quantity",
+  },
+  {
+    align: "right",
+    field: "price_sale",
+    label: "Preço",
+    name: "price_sale",
+  },
+  {
+    align: "right",
+    field: "subtotal",
+    label: "Subtotal",
+    name: "subtotal",
+  },
+];
+
 const handleImport = async () => {
-  if (!selectedFile.value) return;
-
-  loading.value = true;
-  try {
-    importedRows.value = await importProducts(selectedFile.value);
-  } catch (err) {
-    const msg = err?.response?.data?.message ?? "Erro ao importar arquivo.";
-    $q.notify({ type: "negative", message: msg });
-  } finally {
-    loading.value = false;
-  }
+  await execute(async () => {
+    importedRows.value = await importProducts(form.file);
+  });
 };
-</script>
+</script>

+ 69 - 30
src/pages/support/components/AddEditReplyDialog.vue

@@ -8,28 +8,37 @@
         />
 
         <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
               <DefaultInput
-                v-model="replyText"
+                v-model="form.reply"
+                v-model:error="validationErrors.reply"
+                class="col-12"
                 label="Descreva a resposta"
                 type="textarea"
-                class="col-12"
+                :error-message="validationErrors.reply"
+                :rules="[inputRules.required]"
               />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
             <q-btn
-              outline
-              color="primary"
+              color="negative"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
               color="primary"
-              :label="replyItem ? 'Salvar' : 'Responder'"
+              no-caps
               type="submit"
+              unelevated
+              :label="replyItem ? 'Salvar' : 'Responder'"
               :loading="loading"
             />
           </q-card-actions>
@@ -40,45 +49,75 @@
 </template>
 
 <script setup>
+import {
+  createSupportReply,
+  updateSupportReply,
+} from "src/api/support_reply";
+
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import { createSupportReply, updateSupportReply } from "src/api/support_reply";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { ticketId, replyItem } = defineProps({
-  ticketId: {
-    type: Number,
-    required: true,
-  },
+const { replyItem, ticketId } = defineProps({
   replyItem: {
-    type: Object,
     default: null,
+    type: Object,
+  },
+  ticketId: {
+    required: true,
+    type: Number,
   },
 });
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const loading = ref(false);
-const replyText = ref(replyItem?.reply ?? null);
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm(
+  {
+    reply: replyItem?.reply ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => onDialogOK(true),
+  scrollFn: scrollToComponent,
+});
 
 const onOKClick = async () => {
-  loading.value = true;
-  try {
+  await execute(() => {
     if (replyItem) {
-      await updateSupportReply(ticketId, replyItem.id, {
-        reply: replyText.value,
-      });
-    } else {
-      await createSupportReply(ticketId, { reply: replyText.value });
+      return updateSupportReply(
+        ticketId,
+        replyItem.id,
+        getUpdatedFields.value,
+      );
     }
-    onDialogOK(true);
-  } finally {
-    loading.value = false;
-  }
+
+    return createSupportReply(ticketId, {
+      reply: form.reply,
+    });
+  });
 };
-</script>
+</script>

+ 239 - 104
src/pages/support/components/AddEditTicketDialog.vue

@@ -23,73 +23,94 @@
             overflow: hidden;
           "
           @submit="onOKClick"
+          @validation-error="handleValidationError"
         >
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
               <CustomTabComponent
                 v-if="ticket?.id"
                 v-model:active-tab="currentTab"
+                class="col-12 q-mb-md"
                 :tabs="tabs"
-                class="q-mb-md"
               />
 
-              <!-- Tab: Ticket -->
-              <div v-show="currentTab === 'ticket'">
-                <div class="row q-col-gutter-sm">
+              <div
+                v-show="currentTab === 'ticket'"
+                class="col-12"
+              >
+                <div class="row q-col-gutter-md">
                   <DefaultInput
                     v-model="form.title"
-                    label="Título da Tarefa"
+                    v-model:error="validationErrors.title"
                     class="col-12"
+                    label="Título da Tarefa"
+                    :error-message="validationErrors.title"
+                    :rules="[inputRules.required, maxLengthRule(255)]"
                   />
 
                   <DefaultSelect
                     v-model="form.severity"
-                    label="Prioridade"
-                    :options="priorityOptions"
+                    v-model:error="validationErrors.severity"
+                    class="col-6"
                     emit-value
+                    label="Prioridade"
                     map-options
-                    class="col-6"
+                    :error-message="validationErrors.severity"
+                    :options="priorityOptions"
+                    :rules="[inputRules.required]"
                   />
 
                   <DefaultInput
-                    :model-value="user?.name"
-                    label="Responsável"
-                    disable
                     class="col-6"
+                    disable
+                    label="Responsável"
+                    :model-value="user?.name"
                   />
 
                   <DefaultSelect
                     v-model="form.scope"
-                    label="Destino"
-                    :options="unitTargetOptions"
+                    v-model:error="validationErrors.scope"
+                    class="col-6"
                     emit-value
+                    label="Destino"
                     map-options
-                    class="col-6"
+                    :error-message="validationErrors.scope"
+                    :options="unitTargetOptions"
+                    :rules="[inputRules.required]"
                   />
 
                   <DefaultInput
                     v-model="form.sector"
-                    label="Setor"
+                    v-model:error="validationErrors.sector"
                     class="col-6"
+                    label="Setor"
+                    :error-message="validationErrors.sector"
+                    :rules="[maxLengthRule(255)]"
                   />
 
                   <UnitSelect
                     v-if="form.scope === 'specific'"
                     v-model="selectedUnit"
+                    v-model:error="validationErrors.target_unit_id"
                     class="col-6"
+                    :error-message="validationErrors.target_unit_id"
                   />
 
                   <DefaultInput
                     v-model="form.description"
+                    v-model:error="validationErrors.description"
+                    class="col-12"
                     label="Descrição"
                     type="textarea"
-                    class="col-12"
+                    :error-message="validationErrors.description"
                   />
                 </div>
               </div>
 
-              <!-- Tab: Comentários -->
-              <div v-show="currentTab === 'comentarios'">
+              <div
+                v-show="currentTab === 'comentarios'"
+                class="col-12"
+              >
                 <div
                   v-if="canAdd && ticket?.status === 'in_progress'"
                   class="flex justify-end q-mb-sm"
@@ -97,25 +118,33 @@
                   <q-btn
                     color="primary"
                     icon="mdi-plus"
-                    unelevated
                     style="width: 40px; height: 40px"
+                    unelevated
                     @click="onAddComment"
                   />
                 </div>
-                <div style="display: flex; flex-direction: column; gap: 8px">
+
+                <div
+                  style="
+                    display: flex;
+                    flex-direction: column;
+                    gap: 8px;
+                  "
+                >
                   <template v-if="replies.length">
                     <TicketCommentCard
                       v-for="reply in replies"
                       :key="reply.id"
-                      :reply="reply.reply"
+                      :can-delete="canDelete"
+                      :can-edit="canEdit"
                       :created-at="reply.created_at"
+                      :reply="reply.reply"
                       :user-name="reply.user_name"
-                      :can-edit="canEdit"
-                      :can-delete="canDelete"
-                      @edit="onEditComment(reply)"
                       @delete="onDeleteComment(reply)"
+                      @edit="onEditComment(reply)"
                     />
                   </template>
+
                   <div
                     v-else
                     class="flex flex-center full-height text-grey-5 text-body2"
@@ -127,24 +156,30 @@
             </q-card-section>
           </q-scroll-area>
 
+          <q-separator />
+
           <q-card-actions
             align="right"
-            class="q-px-md q-pb-md"
+            class="q-pa-md q-gutter-sm"
             style="flex-shrink: 0"
           >
             <q-btn
-              outline
-              color="primary"
+              color="negative"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
               v-if="canEdit && ticket?.id && ticket?.status === 'in_progress'"
-              outline
               color="negative"
               label="Encerrar"
+              no-caps
+              outline
               @click="onCloseTicket"
             />
+
             <q-btn
               v-if="
                 (!ticket?.id && canAdd) ||
@@ -152,7 +187,9 @@
               "
               color="primary"
               label="Salvar"
+              no-caps
               type="submit"
+              unelevated
               :loading="loading"
             />
           </q-card-actions>
@@ -163,64 +200,133 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
-import { useDialogPluginComponent } from "quasar";
+import { computed, nextTick, onMounted, ref, watch } from "vue";
 
-import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import UnitSelect from "src/components/selects/UnitSelect.vue";
-import TicketCommentCard from "./TicketCommentCard.vue";
-import { userStore } from "src/stores/user";
-import { useQuasar } from "quasar";
 import {
   createSupportTicket,
   updateSupportTicket,
 } from "src/api/support_ticket";
-import CloseTicketDialog from "./CloseTicketDialog.vue";
-import AddEditReplyDialog from "./AddEditReplyDialog.vue";
-import { getSupportReplies, deleteSupportReply } from "src/api/support_reply";
+
+import {
+  deleteSupportReply,
+  getSupportReplies,
+} from "src/api/support_reply";
+
 import { permissionStore } from "src/stores/permission";
+import { userStore } from "src/stores/user";
+
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import AddEditReplyDialog from "./AddEditReplyDialog.vue";
+import CloseTicketDialog from "./CloseTicketDialog.vue";
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+import TicketCommentCard from "./TicketCommentCard.vue";
+import UnitSelect from "src/components/selects/UnitSelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { ticket } = defineProps({
   ticket: {
-    type: Object,
     default: null,
+    type: Object,
   },
 });
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
-const permissions = permissionStore();
-const canAdd = permissions.getAccess("franchisor_support", "add");
-const canEdit = permissions.getAccess("franchisor_support", "edit");
-const canDelete = permissions.getAccess("franchisor_support", "delete");
 const { user } = userStore();
+const { inputRules } = useInputRules();
+
+const permissions = permissionStore();
 
 const formRef = ref(null);
-const loading = ref(false);
-const currentTab = ref("ticket");
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm(
+  {
+    description: ticket?.description ?? null,
+    scope: ticket?.scope ?? null,
+    sector: ticket?.sector ?? null,
+    severity: ticket?.severity ?? null,
+    target_unit_id: ticket?.target_unit_id ?? null,
+    title: ticket?.title ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const scrollToTicketComponent = async (
+  component,
+  containerRef,
+  options,
+) => {
+  currentTab.value = "ticket";
+
+  await nextTick();
+
+  return scrollToComponent(component, containerRef, options);
+};
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => onDialogOK(true),
+  scrollFn: scrollToTicketComponent,
+});
+
 const selectedUnit = ref(
-  ticket?.target_unit_id ? { value: ticket.target_unit_id } : null,
+  ticket?.target_unit_id
+    ? {
+        value: ticket.target_unit_id,
+      }
+    : null,
 );
 
+const currentTab = ref("ticket");
+const replies = ref([]);
+
 const tabs = computed(() => {
-  const base = [{ name: "ticket", label: "Ticket" }];
-  if (ticket?.id) base.push({ name: "comentarios", label: "Comentários" });
+  const base = [
+    {
+      label: "Ticket",
+      name: "ticket",
+    },
+  ];
+
+  if (ticket?.id) {
+    base.push({
+      label: "Comentários",
+      name: "comentarios",
+    });
+  }
+
   return base;
 });
 
-const replies = ref([]);
-
-const loadReplies = async () => {
-  if (!ticket?.id) return;
-  replies.value = await getSupportReplies(ticket.id);
-};
+const canAdd = permissions.getAccess("franchisor_support", "add");
+const canDelete = permissions.getAccess("franchisor_support", "delete");
+const canEdit = permissions.getAccess("franchisor_support", "edit");
 
 const priorityOptions = [
   { label: "Alta", value: "alta" },
@@ -234,85 +340,114 @@ const unitTargetOptions = [
   { label: "Unidade Específica", value: "specific" },
 ];
 
-const form = ref({
-  title: ticket?.title ?? null,
-  severity: ticket?.severity ?? null,
-  scope: ticket?.scope ?? null,
-  sector: ticket?.sector ?? null,
-  description: ticket?.description ?? null,
-});
-
 const buildPayload = () => {
   const payload = {
-    title: form.value.title,
-    severity: form.value.severity,
-    scope: form.value.scope,
-    sector: form.value.sector || null,
-    description: form.value.description || null,
+    description: form.description || null,
+    scope: form.scope,
+    sector: form.sector || null,
+    severity: form.severity,
+    title: form.title,
   };
 
-  if (form.value.scope === "specific") {
-    payload.target_unit_id = selectedUnit.value?.value ?? null;
+  if (form.scope === "specific") {
+    payload.target_unit_id = form.target_unit_id;
   }
 
   return payload;
 };
 
+const handleValidationError = async (component) => {
+  currentTab.value = "ticket";
+
+  await nextTick();
+
+  if (component) {
+    component.focus?.();
+    scrollToComponent(component, scrollAreaRef);
+  }
+};
+
+const loadReplies = async () => {
+  if (!ticket?.id) return;
+
+  replies.value = await getSupportReplies(ticket.id);
+};
+
 const onAddComment = () => {
   $q.dialog({
     component: AddEditReplyDialog,
-    componentProps: { ticketId: ticket.id },
+    componentProps: {
+      ticketId: ticket.id,
+    },
   }).onOk(() => {
     loadReplies();
   });
 };
 
-const onEditComment = (reply) => {
+const onCloseTicket = () => {
   $q.dialog({
-    component: AddEditReplyDialog,
-    componentProps: { ticketId: ticket.id, replyItem: reply },
+    component: CloseTicketDialog,
+    componentProps: {
+      ticket,
+    },
   }).onOk(() => {
-    loadReplies();
+    onDialogOK(true);
   });
 };
 
 const onDeleteComment = (reply) => {
   $q.dialog({
-    title: "Excluir Comentário",
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
     message: "Tem certeza que deseja excluir este comentário?",
-    cancel: { outline: true, color: "primary", label: "Cancelar" },
-    ok: { color: "negative", label: "Excluir" },
+    ok: {
+      color: "negative",
+      label: "Excluir",
+    },
+    title: "Excluir Comentário",
   }).onOk(async () => {
     await deleteSupportReply(ticket.id, reply.id);
+
     loadReplies();
   });
 };
 
-onMounted(() => {
-  loadReplies();
-});
-
-const onCloseTicket = () => {
+const onEditComment = (reply) => {
   $q.dialog({
-    component: CloseTicketDialog,
-    componentProps: { ticket },
+    component: AddEditReplyDialog,
+    componentProps: {
+      replyItem: reply,
+      ticketId: ticket.id,
+    },
   }).onOk(() => {
-    onDialogOK(true);
+    loadReplies();
   });
 };
 
 const onOKClick = async () => {
-  loading.value = true;
-  try {
-    const payload = buildPayload();
-    if (ticket?.id) {
-      await updateSupportTicket(ticket.id, payload);
-    } else {
-      await createSupportTicket(payload);
-    }
-    onDialogOK(true);
-  } finally {
-    loading.value = false;
-  }
+  await execute(() =>
+    ticket?.id
+      ? updateSupportTicket(ticket.id, getUpdatedFields.value)
+      : createSupportTicket(buildPayload()),
+  );
 };
-</script>
+
+watch(selectedUnit, (unit) => {
+  form.target_unit_id = unit?.value ?? null;
+});
+
+watch(
+  () => form.scope,
+  (scope) => {
+    if (scope !== "specific") {
+      selectedUnit.value = null;
+      form.target_unit_id = null;
+    }
+  },
+);
+
+onMounted(loadReplies);
+</script>

+ 54 - 24
src/pages/support/components/AddReplyDialog.vue

@@ -2,34 +2,40 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <div style="width: 100%; max-width: 600px">
       <q-card class="overflow-hidden" style="width: 100%">
-        <DefaultDialogHeader
-          title="Responder Suporte"
-          @close="onDialogCancel"
-        />
+        <DefaultDialogHeader title="Responder Suporte" @close="onDialogCancel" />
 
         <DefaultForm ref="formRef" @submit="onOKClick">
-          <q-scroll-area class="form-dialog-scroll-area">
-            <q-card-section class="q-pt-sm">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
               <DefaultInput
-                v-model="reply"
+                v-model="form.reply"
+                v-model:error="validationErrors.reply"
+                class="col-12"
                 label="Descreva a resposta"
                 type="textarea"
-                class="col-12"
+                :error-message="validationErrors.reply"
+                :rules="[inputRules.required]"
               />
             </q-card-section>
           </q-scroll-area>
 
-          <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
             <q-btn
-              outline
-              color="primary"
+              color="negative"
               label="Cancelar"
+              no-caps
+              outline
               @click="onDialogCancel"
             />
+
             <q-btn
               color="primary"
               label="Responder"
+              no-caps
               type="submit"
+              unelevated
               :loading="loading"
             />
           </q-card-actions>
@@ -40,35 +46,59 @@
 </template>
 
 <script setup>
+import { createSupportReply } from "src/api/support_reply";
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import { createSupportReply } from "src/api/support_reply";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { ticketId } = defineProps({
   ticketId: {
-    type: Number,
     required: true,
+    type: Number,
   },
 });
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const loading = ref(false);
-const reply = ref(null);
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    reply: null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => onDialogOK(true),
+  scrollFn: scrollToComponent,
+});
 
 const onOKClick = async () => {
-  loading.value = true;
-  try {
-    await createSupportReply(ticketId, { reply: reply.value });
-    onDialogOK(true);
-  } finally {
-    loading.value = false;
-  }
+  await execute(() =>
+    createSupportReply(ticketId, {
+      reply: form.reply,
+    }),
+  );
 };
-</script>
+</script>

+ 18 - 15
src/pages/tbr/TbrPage.vue

@@ -1,29 +1,31 @@
 <template>
-  <div>
+  <div class="tab-page-layout">
     <DefaultHeaderPage title="TBR - Configurações" :show-filter-icon="false" />
 
-    <div class="q-px-sm">
+    <div class="tab-page-body q-px-sm">
       <CustomTabComponent
         v-model:active-tab="currentTab"
-        :tabs="tabs"
         class="q-mb-md"
+        :tabs="tabs"
       />
 
-      <div v-show="currentTab === 'settings'">
-        <SettingsTab />
-      </div>
+      <q-scroll-area class="tab-page-scroll-area">
+        <div v-show="currentTab === 'settings'">
+          <SettingsTab />
+        </div>
 
-      <div v-show="currentTab === 'contracts'">
-        <ContractsTab />
-      </div>
+        <div v-show="currentTab === 'contracts'">
+          <ContractsTab />
+        </div>
 
-      <div v-show="currentTab === 'billings'">
-        <BillingsTab />
-      </div>
+        <div v-show="currentTab === 'billings'">
+          <BillingsTab />
+        </div>
 
-      <div v-show="currentTab === 'inhabitant_classifications'">
-        <InhabitantClassificationsTab />
-      </div>
+        <div v-show="currentTab === 'inhabitant_classifications'">
+          <InhabitantClassificationsTab />
+        </div>
+      </q-scroll-area>
     </div>
   </div>
 </template>
@@ -32,6 +34,7 @@
 import { defineAsyncComponent, ref } from "vue";
 
 import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 
 const SettingsTab = defineAsyncComponent(

+ 23 - 18
src/pages/tbr/components/AddEditInhabitantClassificationDialog.vue

@@ -10,7 +10,7 @@
       />
 
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
           <q-card-section class="row q-col-gutter-sm q-pt-none">
             <DefaultInput
               v-model="form.description"
@@ -52,11 +52,7 @@
               step="0.01"
               type="number"
               :error-message="validationErrors.tbr_percentage"
-              :rules="[
-                (v) =>
-                  (v !== null && v !== undefined && v !== '') ||
-                  $t('validation.rules.required'),
-              ]"
+              :rules="[inputRules.requiredNumber]"
             />
           </q-card-section>
         </q-scroll-area>
@@ -90,9 +86,10 @@ import {
 } from "src/api/inhabitant_classification";
 
 import { useDialogPluginComponent } from "quasar";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useScroll } from "src/composables/useScroll";
 import { useTemplateRef } from "vue";
 
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
@@ -113,16 +110,24 @@ const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
-const { form, getUpdatedFields } = useFormUpdateTracker({
-  description: item?.description ?? "",
-  start: item?.start ?? null,
-  end: item?.end ?? null,
-  tbr_percentage: item ? Number(item.tbr_percentage) * 100 : null,
-});
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm(
+  {
+    description: item?.description ?? "",
+    start: item?.start ?? null,
+    end: item?.end ?? null,
+    tbr_percentage: item ? Number(item.tbr_percentage) * 100 : null,
+  },
+  { containerRef: scrollAreaRef },
+);
 
 const { loading, validationErrors, execute } = useSubmitHandler({
   formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
   onSuccess: () => onDialogOK(true),
 });
 
@@ -134,14 +139,14 @@ const onOKClick = async () => {
     tbr_percentage: (form.tbr_percentage ?? 0) / 100,
   };
 
-  if (getUpdatedFields.value.tbr_percentage !== undefined) {
-    getUpdatedFields.value.tbr_percentage /= 100;
+  const updatedFields = { ...getUpdatedFields.value };
+
+  if (updatedFields.tbr_percentage !== undefined) {
+    updatedFields.tbr_percentage /= 100;
   }
 
   if (item) {
-    await execute(() =>
-      updateInhabitantClassification(item.id, getUpdatedFields.value),
-    );
+    await execute(() => updateInhabitantClassification(item.id, updatedFields));
   } else {
     await execute(() => createInhabitantClassification(payload));
   }

+ 143 - 96
src/pages/tbr/components/AddEditTbrDialog.vue

@@ -10,87 +10,93 @@
       />
 
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
-            <p class="text-subtitle2 q-mb-md">Definir Valores e TBR</p>
-
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="form.year"
-                v-model:error="validationErrors.year"
-                :error-message="validationErrors.year"
-                label="Ano"
-                class="col-3"
-                outlined
-                type="number"
-                min="2000"
-                max="2100"
-                :rules="[inputRules.required]"
-              />
-
-              <DefaultCurrencyInput
-                v-model="form.tbr_value"
-                v-model:error="validationErrors.tbr_value"
-                :error-message="validationErrors.tbr_value"
-                label="TBR $"
-                class="col-3"
-                outlined
-              />
-
-              <DefaultInput
-                v-model="form.royalties_display"
-                v-model:error="validationErrors.royalties_percentage"
-                :error-message="validationErrors.royalties_percentage"
-                label="Royalties %"
-                class="col-2"
-                outlined
-                type="number"
-                min="0"
-                max="100"
-                :rules="[inputRules.required]"
-              />
-
-              <DefaultInput
-                v-model="form.fnm_display"
-                v-model:error="validationErrors.fnm_percentage"
-                :error-message="validationErrors.fnm_percentage"
-                label="FMN %"
-                class="col-2"
-                outlined
-                type="number"
-                min="0"
-                max="100"
-                :rules="[inputRules.required]"
-              />
-
-              <DefaultInput
-                v-model="form.maintenance_display"
-                v-model:error="validationErrors.maintenance_percentage"
-                :error-message="validationErrors.maintenance_percentage"
-                label="Manutenção %"
-                class="col-2"
-                outlined
-                type="number"
-                min="0"
-                max="100"
-                :rules="[inputRules.required]"
-              />
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-subtitle2">
+              Definir Valores e TBR
             </div>
+
+            <DefaultInput
+              v-model="form.year"
+              v-model:error="validationErrors.year"
+              class="col-3"
+              label="Ano"
+              max="2100"
+              min="2000"
+              outlined
+              type="number"
+              :error-message="validationErrors.year"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.tbr_value"
+              v-model:error="validationErrors.tbr_value"
+              class="col-3"
+              label="TBR $"
+              outlined
+              :error-message="validationErrors.tbr_value"
+              :rules="[inputRules.requiredNumber]"
+            />
+
+            <DefaultInput
+              v-model="form.royalties_display"
+              v-model:error="validationErrors.royalties_percentage"
+              class="col-2"
+              label="Royalties %"
+              max="100"
+              min="0"
+              outlined
+              type="number"
+              :error-message="validationErrors.royalties_percentage"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.fnm_display"
+              v-model:error="validationErrors.fnm_percentage"
+              class="col-2"
+              label="FMN %"
+              max="100"
+              min="0"
+              outlined
+              type="number"
+              :error-message="validationErrors.fnm_percentage"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.maintenance_display"
+              v-model:error="validationErrors.maintenance_percentage"
+              class="col-2"
+              label="Manutenção %"
+              max="100"
+              min="0"
+              outlined
+              type="number"
+              :error-message="validationErrors.maintenance_percentage"
+              :rules="[inputRules.required]"
+            />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions>
-          <q-space />
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
             color="negative"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            color="primary-2"
+            color="primary"
             label="Salvar"
+            no-caps
             type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -100,53 +106,94 @@
 </template>
 
 <script setup>
+import {
+  createTbr,
+  updateTbr,
+} from "src/api/tbr";
+
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createTbr, updateTbr } from "src/api/tbr";
 
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { tbr } = defineProps({
   tbr: {
-    type: Object,
     default: null,
+    type: Object,
   },
 });
 
-const { inputRules } = useInputRules();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
-
-const form = ref({
-  year: tbr?.year ?? null,
-  tbr_value: tbr ? Number(tbr.tbr_value) : null,
-  royalties_display: tbr ? Number(tbr.royalties_percentage) * 100 : null,
-  fnm_display: tbr ? Number(tbr.fnm_percentage) * 100 : null,
-  maintenance_display: tbr ? Number(tbr.maintenance_percentage) * 100 : null,
-});
+const { inputRules } = useInputRules();
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm(
+  {
+    fnm_display: tbr ? Number(tbr.fnm_percentage) * 100 : null,
+    maintenance_display: tbr
+      ? Number(tbr.maintenance_percentage) * 100
+      : null,
+    royalties_display: tbr ? Number(tbr.royalties_percentage) * 100 : null,
+    tbr_value: tbr ? Number(tbr.tbr_value) : null,
+    year: tbr?.year ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
   formRef,
   onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
 });
 
-async function onOKClick() {
-  const payload = {
-    year: form.value.year,
-    tbr_value: form.value.tbr_value,
-    royalties_percentage: (form.value.royalties_display ?? 0) / 100,
-    fnm_percentage: (form.value.fnm_display ?? 0) / 100,
-    maintenance_percentage: (form.value.maintenance_display ?? 0) / 100,
-  };
-
-  await execute(() => (tbr ? updateTbr(tbr.id, payload) : createTbr(payload)));
-}
-</script>
+const onOKClick = async () => {
+  await execute(() => {
+    if (tbr) {
+      const payload = {
+        ...getUpdatedFields.value,
+      };
+
+      for (const [display, field] of [
+        ["royalties_display", "royalties_percentage"],
+        ["fnm_display", "fnm_percentage"],
+        ["maintenance_display", "maintenance_percentage"],
+      ]) {
+        if (display in payload) {
+          payload[field] = Number(payload[display] ?? 0) / 100;
+
+          delete payload[display];
+        }
+      }
+
+      return updateTbr(tbr.id, payload);
+    }
+
+    return createTbr({
+      fnm_percentage: (form.fnm_display ?? 0) / 100,
+      maintenance_percentage: (form.maintenance_display ?? 0) / 100,
+      royalties_percentage: (form.royalties_display ?? 0) / 100,
+      tbr_value: form.tbr_value,
+      year: form.year,
+    });
+  });
+};
+</script>

+ 60 - 13
src/pages/tbr/components/GenerateBillingsDialog.vue

@@ -7,21 +7,39 @@
       <DefaultDialogHeader title="Mês de Cobrança" @close="onDialogCancel" />
 
       <DefaultForm ref="formRef" @submit="onGenerate">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
             <DefaultSelect
-              v-model="month"
+              v-model="form.month"
+              v-model:error="validationErrors.month"
+              class="col-12"
               label="Mês"
+              :error-message="validationErrors.month"
               :options="monthOptions"
               :rules="[inputRules.required]"
             />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions>
-          <q-space />
-          <q-btn outline label="Cancelar" @click="onDialogCancel" />
-          <q-btn color="primary-2" label="Gerar" type="submit" />
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Gerar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
         </q-card-actions>
       </DefaultForm>
     </q-card>
@@ -31,18 +49,43 @@
 <script setup>
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const { inputRules } = useInputRules();
 
-const month = ref(null);
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    month: null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (payload) => onDialogOK(payload),
+  scrollFn: scrollToComponent,
+});
 
 const monthOptions = [
   { label: "Janeiro", value: 1 },
@@ -59,7 +102,11 @@ const monthOptions = [
   { label: "Dezembro", value: 12 },
 ];
 
-function onGenerate() {
-  onDialogOK({ month: month.value });
-}
-</script>
+const onGenerate = async () => {
+  await execute(() =>
+    Promise.resolve({
+      month: form.month,
+    }),
+  );
+};
+</script>

+ 320 - 216
src/pages/tbr/components/GenerateReceivableDialog.vue

@@ -1,302 +1,406 @@
 <template>
   <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="width: 100%; max-width: 1100px">
-      <DefaultDialogHeader title="Gerar Contas a Receber" @close="onDialogCancel" />
-
-      <q-card-section class="q-pt-none">
-        <div class="row q-col-gutter-sm items-end">
-          <DefaultSelect
-            v-model="referenceMonth"
-            label="Mês de Referência"
-            class="col-12 col-md-4"
-            :options="monthOptions"
-            option-value="value"
-            option-label="label"
-            emit-value
-            map-options
-          />
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 100%; max-width: 1100px"
+    >
+      <DefaultDialogHeader
+        title="Gerar Contas a Receber"
+        @close="onDialogCancel"
+      />
 
-          <div class="col-12 col-md-3 text-body2 q-pb-sm">
-            Ano: <b>{{ referenceYear }}</b>
-          </div>
-
-          <div class="col-12 col-md-5 text-right">
-            <q-btn
-              color="primary"
-              outline
-              icon="mdi-calculator"
-              label="Calcular Preview"
-              :loading="loadingPreview"
-              @click="loadPreview"
+      <DefaultForm ref="formRef" @submit="onGenerateAll">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <DefaultSelect
+              v-model="form.reference_month"
+              v-model:error="validationErrors.reference_month"
+              class="col-12 col-md-4"
+              emit-value
+              label="Mês de Referência"
+              map-options
+              option-label="label"
+              option-value="value"
+              :error-message="validationErrors.reference_month"
+              :options="monthOptions"
+              :rules="[inputRules.requiredNumber]"
             />
-          </div>
-        </div>
-
-        <q-banner v-if="error" class="bg-negative text-white q-mt-md" dense>
-          {{ error }}
-        </q-banner>
-
-        <q-banner v-else-if="loaded && previews.length === 0" class="bg-warning text-white q-mt-md" dense>
-          Nenhum contrato vigente encontrado para o mês informado.
-        </q-banner>
-
-        <q-table
-          v-if="previews.length > 0"
-          flat
-          dense
-          :rows="previews"
-          :columns="columns"
-          row-key="unit_id"
-          class="q-mt-md"
-          :pagination="{ rowsPerPage: 0 }"
-          hide-pagination
-        >
-          <template #body-cell-tbr_value="{ row }">
-            <q-td>{{ formatToBRLCurrency(row.tbr_value) }}</q-td>
-          </template>
-
-          <template #body-cell-royalties_effective_value="{ row }">
-            <q-td>
-              {{ formatToBRLCurrency(row.royalties_effective_value) }}
-              <div class="text-caption text-grey-7">
-                {{ formatPercentage(row.royalties_effective_percentage) }} ({{ row.royalties_applied_criteria }})
-              </div>
-            </q-td>
-          </template>
-
-          <template #body-cell-fnm_effective_value="{ row }">
-            <q-td>
-              {{ formatToBRLCurrency(row.fnm_effective_value) }}
-              <div class="text-caption text-grey-7">
-                {{ formatPercentage(row.fnm_effective_percentage) }}
-              </div>
-            </q-td>
-          </template>
-
-          <template #body-cell-maintenance_effective_value="{ row }">
-            <q-td>
-              {{ formatToBRLCurrency(row.maintenance_effective_value) }}
-              <div class="text-caption text-grey-7">
-                {{ formatPercentage(row.maintenance_effective_percentage) }}
-              </div>
-            </q-td>
-          </template>
-
-          <template #body-cell-final_value="{ row }">
-            <q-td>
-              <b>{{ formatToBRLCurrency(row.final_value) }}</b>
-            </q-td>
-          </template>
-
-          <template #body-cell-status="{ row }">
-            <q-td>
-              <q-chip
-                v-if="row.error"
-                color="negative"
-                text-color="white"
-                dense
-                :label="row.error"
+
+            <div class="col-12 col-md-3 text-body2 q-pb-sm">
+              Ano *: <b>{{ form.reference_year }}</b>
+            </div>
+
+            <div class="col-12 col-md-5 text-right">
+              <q-btn
+                color="primary"
+                icon="mdi-calculator"
+                label="Calcular Preview"
+                no-caps
+                outline
+                :loading="loadingPreview"
+                @click="loadPreview"
               />
-              <q-chip
-                v-else-if="row.receivable_already_generated"
-                color="warning"
-                text-color="white"
+            </div>
+
+            <q-banner
+              v-if="error"
+              class="col-12 bg-negative text-white"
+              dense
+            >
+              {{ error }}
+            </q-banner>
+
+            <q-banner
+              v-else-if="loaded && previews.length === 0"
+              class="col-12 bg-warning text-white"
+              dense
+            >
+              Nenhum contrato vigente encontrado para o mês informado.
+            </q-banner>
+
+            <div v-if="previews.length > 0" class="col-12">
+              <q-table
                 dense
-                label="Já gerado"
-              />
-              <q-chip v-else color="positive" text-color="white" dense label="Pronto" />
-            </q-td>
-          </template>
-        </q-table>
-
-        <div v-if="previews.length > 0" class="row q-mt-md q-gutter-sm items-center">
-          <div class="col">
-            <div class="text-body2">
+                flat
+                hide-pagination
+                row-key="unit_id"
+                :columns="columns"
+                :pagination="{ rowsPerPage: 0 }"
+                :rows="previews"
+              >
+                <template #body-cell-tbr_value="{ row }">
+                  <q-td>
+                    {{ formatToBRLCurrency(row.tbr_value) }}
+                  </q-td>
+                </template>
+
+                <template #body-cell-royalties_effective_value="{ row }">
+                  <q-td>
+                    {{ formatToBRLCurrency(row.royalties_effective_value) }}
+
+                    <div class="text-caption text-grey-7">
+                      {{
+                        formatPercentage(row.royalties_effective_percentage)
+                      }}
+                      ({{ row.royalties_applied_criteria }})
+                    </div>
+                  </q-td>
+                </template>
+
+                <template #body-cell-fnm_effective_value="{ row }">
+                  <q-td>
+                    {{ formatToBRLCurrency(row.fnm_effective_value) }}
+
+                    <div class="text-caption text-grey-7">
+                      {{ formatPercentage(row.fnm_effective_percentage) }}
+                    </div>
+                  </q-td>
+                </template>
+
+                <template #body-cell-maintenance_effective_value="{ row }">
+                  <q-td>
+                    {{ formatToBRLCurrency(row.maintenance_effective_value) }}
+
+                    <div class="text-caption text-grey-7">
+                      {{
+                        formatPercentage(row.maintenance_effective_percentage)
+                      }}
+                    </div>
+                  </q-td>
+                </template>
+
+                <template #body-cell-final_value="{ row }">
+                  <q-td>
+                    <b>{{ formatToBRLCurrency(row.final_value) }}</b>
+                  </q-td>
+                </template>
+
+                <template #body-cell-status="{ row }">
+                  <q-td>
+                    <q-chip
+                      v-if="row.error"
+                      color="negative"
+                      dense
+                      text-color="white"
+                      :label="row.error"
+                    />
+
+                    <q-chip
+                      v-else-if="row.receivable_already_generated"
+                      color="warning"
+                      dense
+                      label="Já gerado"
+                      text-color="white"
+                    />
+
+                    <q-chip
+                      v-else
+                      color="positive"
+                      dense
+                      label="Pronto"
+                      text-color="white"
+                    />
+                  </q-td>
+                </template>
+              </q-table>
+            </div>
+
+            <div
+              v-if="previews.length > 0"
+              class="col-12 text-body2"
+            >
               Total geral a cobrar:
               <b>{{ formatToBRLCurrency(totalToBill) }}</b>
               em <b>{{ readyCount }}</b> unidade(s) pronta(s) para geração.
             </div>
-          </div>
-        </div>
-      </q-card-section>
-
-      <q-card-actions>
-        <q-space />
-        <q-btn outline label="Fechar" @click="onDialogCancel" />
-        <q-btn
-          color="primary-2"
-          label="Gerar para Todas Pendentes"
-          icon="mdi-content-save-outline"
-          :loading="loadingGenerate"
-          :disable="readyCount === 0"
-          @click="onGenerateAll"
-        />
-      </q-card-actions>
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Fechar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            icon="mdi-content-save-outline"
+            label="Gerar para Todas Pendentes"
+            no-caps
+            type="submit"
+            unelevated
+            :disable="readyCount === 0"
+            :loading="loadingGenerate"
+          />
+        </q-card-actions>
+      </DefaultForm>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
 import { computed, ref } from "vue";
-import { useDialogPluginComponent, useQuasar } from "quasar";
 
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import {
+  formatPercentage,
+  formatToBRLCurrency,
+} from "src/helpers/utils";
 
 import {
-  previewBatchTbrCalculation,
   generateBatchReceivables,
+  previewBatchTbrCalculation,
 } from "src/api/tbr_calculation";
-import { formatToBRLCurrency, formatPercentage } from "src/helpers/utils";
+
+import {
+  useDialogPluginComponent,
+  useQuasar,
+} from "quasar";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const $q = useQuasar();
+const { inputRules } = useInputRules();
+
+const { scrollToComponent } = useScroll();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
 
 const today = new Date();
-const referenceMonth = ref(today.getMonth() + 1);
-const referenceYear = ref(today.getFullYear());
 
-const previews = ref([]);
-const loadingPreview = ref(false);
-const loadingGenerate = ref(false);
-const loaded = ref(false);
+const { form } = useForm(
+  {
+    reference_month: today.getMonth() + 1,
+    reference_year: today.getFullYear(),
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading: loadingGenerate,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onError: (error) => {
+    $q.notify({
+      message:
+        error?.response?.data?.message ||
+        "Erro ao gerar Contas a Receber.",
+      type: "negative",
+    });
+  },
+  scrollFn: scrollToComponent,
+});
+
 const error = ref(null);
+const loaded = ref(false);
+const loadingPreview = ref(false);
+const previews = ref([]);
 
-const monthOptions = [
-  { value: 1, label: "Janeiro" },
-  { value: 2, label: "Fevereiro" },
-  { value: 3, label: "Março" },
-  { value: 4, label: "Abril" },
-  { value: 5, label: "Maio" },
-  { value: 6, label: "Junho" },
-  { value: 7, label: "Julho" },
-  { value: 8, label: "Agosto" },
-  { value: 9, label: "Setembro" },
-  { value: 10, label: "Outubro" },
-  { value: 11, label: "Novembro" },
-  { value: 12, label: "Dezembro" },
-];
+const readyCount = computed(
+  () =>
+    previews.value.filter(
+      (preview) =>
+        !preview.error && !preview.receivable_already_generated,
+    ).length,
+);
+
+const totalToBill = computed(() =>
+  previews.value
+    .filter(
+      (preview) =>
+        !preview.error && !preview.receivable_already_generated,
+    )
+    .reduce(
+      (sum, preview) => sum + Number(preview.final_value || 0),
+      0,
+    ),
+);
 
 const columns = [
   {
-    name: "unit_name",
-    label: "Unidade",
-    field: (row) => row.unit_name ?? `#${row.unit_id}`,
     align: "left",
+    field: (row) => row.unit_name ?? `#${row.unit_id}`,
+    label: "Unidade",
+    name: "unit_name",
   },
   {
-    name: "municipality_size_name",
-    label: "Porte",
-    field: "municipality_size_name",
     align: "left",
+    field: "municipality_size_name",
+    label: "Porte",
+    name: "municipality_size_name",
   },
   {
-    name: "contract_month_reference",
-    label: "Mês Contrato",
-    field: "contract_month_reference",
     align: "center",
+    field: "contract_month_reference",
+    label: "Mês Contrato",
+    name: "contract_month_reference",
   },
   {
-    name: "tbr_value",
-    label: "TBR",
-    field: "tbr_value",
     align: "left",
+    field: "tbr_value",
+    label: "TBR",
+    name: "tbr_value",
   },
   {
-    name: "royalties_effective_value",
-    label: "Royalties",
-    field: "royalties_effective_value",
     align: "left",
+    field: "royalties_effective_value",
+    label: "Royalties",
+    name: "royalties_effective_value",
   },
   {
-    name: "fnm_effective_value",
-    label: "FNM",
-    field: "fnm_effective_value",
     align: "left",
+    field: "fnm_effective_value",
+    label: "FNM",
+    name: "fnm_effective_value",
   },
   {
-    name: "maintenance_effective_value",
-    label: "Manutenção",
-    field: "maintenance_effective_value",
     align: "left",
+    field: "maintenance_effective_value",
+    label: "Manutenção",
+    name: "maintenance_effective_value",
   },
   {
-    name: "final_value",
-    label: "Total",
-    field: "final_value",
     align: "left",
+    field: "final_value",
+    label: "Total",
+    name: "final_value",
   },
   {
-    name: "status",
-    label: "Status",
-    field: "status",
     align: "left",
+    field: "status",
+    label: "Status",
+    name: "status",
   },
 ];
 
-const readyCount = computed(
-  () =>
-    previews.value.filter((p) => !p.error && !p.receivable_already_generated)
-      .length,
-);
-
-const totalToBill = computed(() =>
-  previews.value
-    .filter((p) => !p.error && !p.receivable_already_generated)
-    .reduce((sum, p) => sum + Number(p.final_value || 0), 0),
-);
+const monthOptions = [
+  { label: "Janeiro", value: 1 },
+  { label: "Fevereiro", value: 2 },
+  { label: "Março", value: 3 },
+  { label: "Abril", value: 4 },
+  { label: "Maio", value: 5 },
+  { label: "Junho", value: 6 },
+  { label: "Julho", value: 7 },
+  { label: "Agosto", value: 8 },
+  { label: "Setembro", value: 9 },
+  { label: "Outubro", value: 10 },
+  { label: "Novembro", value: 11 },
+  { label: "Dezembro", value: 12 },
+];
 
-async function loadPreview() {
-  loadingPreview.value = true;
+const loadPreview = async () => {
   error.value = null;
+  loadingPreview.value = true;
+
   try {
     previews.value = await previewBatchTbrCalculation({
-      reference_year: referenceYear.value,
-      reference_month: referenceMonth.value,
+      reference_month: form.reference_month,
+      reference_year: form.reference_year,
     });
+
     loaded.value = true;
-  } catch (err) {
+  } catch (errorResponse) {
     error.value =
-      err?.response?.data?.message || "Erro ao calcular o preview em lote.";
+      errorResponse?.response?.data?.message ||
+      "Erro ao calcular o preview em lote.";
+
     previews.value = [];
   } finally {
     loadingPreview.value = false;
   }
-}
+};
 
-async function onGenerateAll() {
+const onGenerateAll = async () => {
   $q.dialog({
-    title: "Confirmar geração",
+    cancel: {
+      color: "primary",
+      label: "Cancelar",
+      outline: true,
+    },
     message: `Gerar Contas a Receber para ${readyCount.value} unidade(s)?`,
-    ok: { label: "Gerar", color: "primary" },
-    cancel: { label: "Cancelar", color: "primary", outline: true },
+    ok: {
+      color: "primary",
+      label: "Gerar",
+    },
+    title: "Confirmar geração",
   }).onOk(async () => {
-    loadingGenerate.value = true;
-    try {
-      const result = await generateBatchReceivables({
-        reference_year: referenceYear.value,
-        reference_month: referenceMonth.value,
-      });
+    const result = await execute(() =>
+      generateBatchReceivables({
+        reference_month: form.reference_month,
+        reference_year: form.reference_year,
+      }),
+    );
+
+    if (result) {
       $q.notify({
-        type: "positive",
         message:
           result.message ||
           `${result.payload?.generated_count ?? 0} título(s) gerado(s).`,
+        type: "positive",
       });
+
       onDialogOK(result.payload ?? null);
-    } catch (err) {
-      $q.notify({
-        type: "negative",
-        message:
-          err?.response?.data?.message || "Erro ao gerar Contas a Receber.",
-      });
-    } finally {
-      loadingGenerate.value = false;
     }
   });
-}
-</script>
+};
+</script>

+ 40 - 10
src/pages/unit/UnitActionPage.vue

@@ -1,43 +1,73 @@
 <template>
-  <div>
+  <div class="tab-page-layout">
     <DefaultHeaderPage title="Cadastro de Unidade" />
 
     <CustomTabComponent v-model:active-tab="activeTab" :tabs />
 
     <UnitDataTab
       v-show="activeTab === 'unit_data'"
+      class="tab-page-flex-content"
       :unit-id="unitId"
     />
 
     <template v-if="unitId">
-      <PartnersTab v-show="activeTab === 'partners'" :unit-id="unitId" />
-      <ContractsTab v-show="activeTab === 'contracts'" :unit-id="unitId" />
-      <FinancialTab v-show="activeTab === 'financial'" :unit-id="unitId" />
-      <HistoryTab v-show="activeTab === 'history'" :unit-id="unitId" />
-      <MediasTab v-show="activeTab === 'medias'" :unit-id="unitId" />
+      <FinancialTab
+        v-show="activeTab === 'financial'"
+        class="tab-page-flex-content"
+        :unit-id="unitId"
+      />
+
+      <q-scroll-area
+        v-show="!['unit_data', 'financial'].includes(activeTab)"
+        class="tab-page-scroll-area"
+      >
+        <PartnersTab
+          v-show="activeTab === 'partners'"
+          :unit-id="unitId"
+        />
+
+        <ContractsTab
+          v-show="activeTab === 'contracts'"
+          :unit-id="unitId"
+        />
+
+        <HistoryTab
+          v-show="activeTab === 'history'"
+          :unit-id="unitId"
+        />
+
+        <MediasTab
+          v-show="activeTab === 'medias'"
+          :unit-id="unitId"
+        />
+      </q-scroll-area>
     </template>
   </div>
 </template>
 
 <script setup>
+import { computed, ref } from "vue";
+import { useRoute } from "vue-router";
+
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
 import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+
 import UnitDataTab from "src/pages/unit/tabs/UnitDataTab.vue";
 import PartnersTab from "src/pages/unit/tabs/PartnersTab.vue";
 import ContractsTab from "src/pages/unit/tabs/ContractsTab.vue";
 import FinancialTab from "src/pages/unit/tabs/FinancialTab.vue";
 import HistoryTab from "src/pages/unit/tabs/HistoryTab.vue";
 import MediasTab from "src/pages/unit/tabs/MediasTab.vue";
-import { ref, computed } from "vue";
-import { useRoute } from "vue-router";
 
 const route = useRoute();
+
+const activeTab = ref("unit_data");
+
 const unitId = computed(() =>
   route.params.id ? Number(route.params.id) : null,
 );
 
-const activeTab = ref("unit_data");
-
 const allTabs = [
   { name: "unit_data", label: "Dados da Unidade" },
   { name: "partners", label: "Sócios" },

+ 85 - 50
src/pages/unit/components/AddEditHistoryDialog.vue

@@ -10,47 +10,55 @@
       />
 
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
-            <div class="column q-gutter-sm">
-              <DefaultInput
-                v-model="form.title"
-                v-model:error="validationErrors.title"
-                :error-message="validationErrors.title"
-                label="Título"
-                outlined
-                :rules="[inputRules.required]"
-              />
-              <q-input
-                v-model="form.content"
-                :error="!!validationErrors.content"
-                :error-message="validationErrors.content"
-                label="Conteúdo"
-                outlined
-                type="textarea"
-                rows="5"
-                autogrow
-              />
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <DefaultInput
+              v-model="form.title"
+              v-model:error="validationErrors.title"
+              class="col-12"
+              label="Título"
+              outlined
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.content"
+              v-model:error="validationErrors.content"
+              autogrow
+              class="col-12"
+              label="Conteúdo"
+              outlined
+              rows="5"
+              type="textarea"
+            />
+
+            <div class="col-12">
               <q-toggle
                 v-model="form.visible_to_franchisee"
-                label="Visível ao franqueado"
                 color="positive"
+                label="Visível ao franqueado"
               />
             </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-pa-md">
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
-            color="primary"
+            color="negative"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            color="primary-2"
-            :label="history ? 'Salvar' : 'Adicionar'"
+            color="primary"
+            no-caps
             type="submit"
+            unelevated
+            :label="history ? 'Salvar' : 'Adicionar'"
             :loading="loading"
           />
         </q-card-actions>
@@ -60,50 +68,77 @@
 </template>
 
 <script setup>
+
+import {
+  createHistory,
+  updateHistory,
+} from "src/api/unit_history";
+
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createHistory, updateHistory } from "src/api/unit_history";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { history, unitId } = defineProps({
-  history: { type: Object, default: null },
-  unitId: { type: Number, required: true },
+  history: {
+    default: null,
+    type: Object,
+  },
+  unitId: {
+    required: true,
+    type: Number,
+  },
 });
 
-const { inputRules } = useInputRules();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
+const { inputRules } = useInputRules();
+
 const formRef = ref(null);
+const scrollAreaRef = ref(null);
 
-const form = ref({
-  title: history?.title ?? "",
-  content: history?.content ?? "",
-  visible_to_franchisee: history?.visible_to_franchisee ?? false,
-});
+const { scrollToComponent } = useScroll();
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const { form, getUpdatedFields } = useForm(
+  {
+    content: history?.content ?? "",
+    title: history?.title ?? "",
+    visible_to_franchisee: history?.visible_to_franchisee ?? false,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
   formRef,
   onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
 });
 
-async function onOKClick() {
+const onOKClick = async () => {
   await execute(() => {
-    const payload = {
+    if (history) {
+      return updateHistory(history.id, getUpdatedFields.value);
+    }
+
+    return createHistory({
+      content: form.content,
+      title: form.title,
       unit_id: unitId,
-      title: form.value.title,
-      content: form.value.content,
-      visible_to_franchisee: form.value.visible_to_franchisee,
-    };
-
-    return history
-      ? updateHistory(history.id, payload)
-      : createHistory(payload);
+      visible_to_franchisee: form.visible_to_franchisee,
+    });
   });
-}
-</script>
+};
+</script>

+ 296 - 259
src/pages/unit/components/AddEditPartnerDialog.vue

@@ -10,211 +10,214 @@
       />
 
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
-            <div class="column items-center q-mb-md">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 column items-center">
               <AvatarImageComponent
                 ref="avatarRef"
                 @update:file="onAvatarChange"
               />
             </div>
 
-            <div class="row q-col-gutter-sm">
-              <DefaultInput
-                v-model="form.name"
-                v-model:error="validationErrors.name"
-                :error-message="validationErrors.name"
-                label="Nome completo"
-                class="col-8"
-                outlined
-                :rules="[inputRules.required]"
-              />
-
-              <DefaultInput
-                v-model="form.role"
-                v-model:error="validationErrors.role"
-                :error-message="validationErrors.role"
-                label="Função"
-                class="col-4"
-                outlined
-              />
-
-              <DefaultInput
-                v-model="form.social_name"
-                v-model:error="validationErrors.social_name"
-                :error-message="validationErrors.social_name"
-                label="Nome social"
-                class="col-6"
-                outlined
-              />
-
-              <DefaultInput
-                v-model="form.cpf"
-                v-model:error="validationErrors.cpf"
-                :error-message="validationErrors.cpf"
-                label="CPF"
-                class="col-3"
-                outlined
-                :mask="masks.Brasil.cpf"
-                :rules="[inputRules.required, inputRules.cpf]"
-              />
-
-              <DefaultInput
-                v-model="form.rg"
-                v-model:error="validationErrors.rg"
-                :error-message="validationErrors.rg"
-                label="RG"
-                class="col-3"
-                outlined
-              />
-
-              <DefaultInput
-                v-model="birthDateDisplay"
-                v-model:error="validationErrors.birth_date"
-                :error-message="validationErrors.birth_date"
-                label="Data de Nascimento"
-                class="col-3"
-                outlined
-                :mask="masks.Brasil.date"
-                placeholder="DD/MM/AAAA"
-              />
-
-              <DefaultInput
-                v-model="form.participation"
-                v-model:error="validationErrors.participation"
-                :error-message="validationErrors.participation"
-                label="Participação (%)"
-                class="col-3"
-                outlined
-                type="number"
-                min="0"
-                max="100"
-              />
-
-              <DefaultInput
-                v-model="form.email"
-                v-model:error="validationErrors.email"
-                :error-message="validationErrors.email"
-                label="E-mail"
-                class="col-6"
-                outlined
-                :rules="[inputRules.email]"
-              />
-
-              <DefaultInput
-                v-model="form.secondary_email"
-                v-model:error="validationErrors.secondary_email"
-                :error-message="validationErrors.secondary_email"
-                label="E-mail Secundário"
-                class="col-6"
-                outlined
-                :rules="[inputRules.email]"
-              />
-
-              <DefaultInput
-                v-model="form.phone_number"
-                v-model:error="validationErrors.phone_number"
-                :error-message="validationErrors.phone_number"
-                label="Telefone"
-                class="col-6"
-                outlined
-                :mask="masks.Brasil.telefone"
-              />
-
-              <DefaultInput
-                v-model="form.cell_number"
-                v-model:error="validationErrors.cell_number"
-                :error-message="validationErrors.cell_number"
-                label="Celular"
-                class="col-6"
-                outlined
-                :mask="masks.Brasil.celular"
-              />
-
-              <DefaultCepInput
-                v-model="form.postal_code"
-                v-model:error="validationErrors.postal_code"
-                :error-message="validationErrors.postal_code"
-                class="col-6"
-                outlined
-                @rua="form.street = $event"
-                @bairro="form.neighborhood = $event"
-                @uf="stateSelectRef?.selectStateByCode($event)"
-                @cidade="citySelectRef?.selectCityByName($event)"
-              />
-
-              <DefaultInput
-                v-model="form.street"
-                v-model:error="validationErrors.street"
-                :error-message="validationErrors.street"
-                label="Endereço"
-                class="col-6"
-                outlined
-              />
-
-              <DefaultInput
-                v-model="form.address_number"
-                v-model:error="validationErrors.address_number"
-                :error-message="validationErrors.address_number"
-                label="Número"
-                class="col-6"
-                outlined
-              />
-
-              <DefaultInput
-                v-model="form.neighborhood"
-                v-model:error="validationErrors.neighborhood"
-                :error-message="validationErrors.neighborhood"
-                label="Bairro"
-                class="col-4"
-                outlined
-              />
-
-              <StateSelect
-                ref="stateSelectRef"
-                v-model="selectedState"
-                v-model:error="validationErrors.state_id"
-                :error-message="validationErrors.state_id"
-                label="Estado"
-                class="col-4"
-                outlined
-              />
-
-              <CitySelect
-                ref="citySelectRef"
-                v-model="selectedCity"
-                v-model:error="validationErrors.city_id"
-                :error-message="validationErrors.city_id"
-                label="Cidade"
-                class="col-4"
-                outlined
-                :state="selectedState"
-              />
-
-              <DefaultInput
-                v-model="form.complement"
-                v-model:error="validationErrors.complement"
-                :error-message="validationErrors.complement"
-                label="Complemento"
-                class="col-12"
-                outlined
-              />
-            </div>
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              class="col-8"
+              label="Nome completo"
+              outlined
+              :error-message="validationErrors.name"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.role"
+              v-model:error="validationErrors.role"
+              class="col-4"
+              label="Função"
+              outlined
+              :error-message="validationErrors.role"
+            />
+
+            <DefaultInput
+              v-model="form.social_name"
+              v-model:error="validationErrors.social_name"
+              class="col-6"
+              label="Nome social"
+              outlined
+              :error-message="validationErrors.social_name"
+            />
+
+            <DefaultInput
+              v-model="form.cpf"
+              v-model:error="validationErrors.cpf"
+              class="col-3"
+              label="CPF"
+              outlined
+              :error-message="validationErrors.cpf"
+              :mask="masks.Brasil.cpf"
+              :rules="[inputRules.required, inputRules.cpf]"
+            />
+
+            <DefaultInput
+              v-model="form.rg"
+              v-model:error="validationErrors.rg"
+              class="col-3"
+              label="RG"
+              outlined
+              :error-message="validationErrors.rg"
+            />
+
+            <DefaultInput
+              v-model="birthDateDisplay"
+              v-model:error="validationErrors.birth_date"
+              class="col-3"
+              label="Data de Nascimento"
+              outlined
+              placeholder="DD/MM/AAAA"
+              :error-message="validationErrors.birth_date"
+              :mask="masks.Brasil.date"
+            />
+
+            <DefaultInput
+              v-model="form.participation"
+              v-model:error="validationErrors.participation"
+              class="col-3"
+              label="Participação (%)"
+              max="100"
+              min="0"
+              outlined
+              type="number"
+              :error-message="validationErrors.participation"
+            />
+
+            <DefaultInput
+              v-model="form.email"
+              v-model:error="validationErrors.email"
+              class="col-6"
+              label="E-mail"
+              outlined
+              :error-message="validationErrors.email"
+              :rules="[inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.secondary_email"
+              v-model:error="validationErrors.secondary_email"
+              class="col-6"
+              label="E-mail Secundário"
+              outlined
+              :error-message="validationErrors.secondary_email"
+              :rules="[inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.phone_number"
+              v-model:error="validationErrors.phone_number"
+              class="col-6"
+              label="Telefone"
+              outlined
+              :error-message="validationErrors.phone_number"
+              :mask="masks.Brasil.telefone"
+            />
+
+            <DefaultInput
+              v-model="form.cell_number"
+              v-model:error="validationErrors.cell_number"
+              class="col-6"
+              label="Celular"
+              outlined
+              :error-message="validationErrors.cell_number"
+              :mask="masks.Brasil.celular"
+            />
+
+            <DefaultCepInput
+              v-model="form.postal_code"
+              v-model:error="validationErrors.postal_code"
+              class="col-6"
+              outlined
+              :error-message="validationErrors.postal_code"
+              @bairro="form.neighborhood = $event"
+              @cidade="citySelectRef?.selectCityByName($event)"
+              @rua="form.street = $event"
+              @uf="stateSelectRef?.selectStateByCode($event)"
+            />
+
+            <DefaultInput
+              v-model="form.street"
+              v-model:error="validationErrors.street"
+              class="col-6"
+              label="Endereço"
+              outlined
+              :error-message="validationErrors.street"
+            />
+
+            <DefaultInput
+              v-model="form.address_number"
+              v-model:error="validationErrors.address_number"
+              class="col-6"
+              label="Número"
+              outlined
+              :error-message="validationErrors.address_number"
+            />
+
+            <DefaultInput
+              v-model="form.neighborhood"
+              v-model:error="validationErrors.neighborhood"
+              class="col-4"
+              label="Bairro"
+              outlined
+              :error-message="validationErrors.neighborhood"
+            />
+
+            <StateSelect
+              ref="stateSelectRef"
+              v-model="selectedState"
+              v-model:error="validationErrors.state_id"
+              class="col-4"
+              label="Estado"
+              outlined
+              :error-message="validationErrors.state_id"
+            />
+
+            <CitySelect
+              ref="citySelectRef"
+              v-model="selectedCity"
+              v-model:error="validationErrors.city_id"
+              class="col-4"
+              label="Cidade"
+              outlined
+              :error-message="validationErrors.city_id"
+              :state="selectedState"
+            />
+
+            <DefaultInput
+              v-model="form.complement"
+              v-model:error="validationErrors.complement"
+              class="col-12"
+              label="Complemento"
+              outlined
+              :error-message="validationErrors.complement"
+            />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions>
-          <q-space />
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
             color="negative"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            color="primary-2"
-            :label="partner ? 'Salvar' : 'Adicionar'"
+            color="primary"
+            no-caps
             type="submit"
+            unelevated
+            :label="partner ? 'Salvar' : 'Adicionar'"
             :loading="loading"
           />
         </q-card-actions>
@@ -224,124 +227,139 @@
 </template>
 
 <script setup>
-import { ref, watch, onMounted } from "vue";
+import {
+  createPartner,
+  updatePartner,
+} from "src/api/unit_partner";
+
+import {
+  formatDateDMYtoYMD,
+  formatDateYMDtoDMY,
+} from "src/helpers/utils";
+
+import { onMounted, ref, watch } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createPartner, updatePartner } from "src/api/unit_partner";
+
 import masks from "src/helpers/masks";
-import { formatDateDMYtoYMD, formatDateYMDtoDMY } from "src/helpers/utils";
 
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+import CitySelect from "src/components/selects/CitySelect.vue";
+import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
-import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
 import StateSelect from "src/components/selects/StateSelect.vue";
-import CitySelect from "src/components/selects/CitySelect.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { partner, unitId, offlineMode } = defineProps({
+const { offlineMode, partner, unitId } = defineProps({
+  offlineMode: {
+    default: false,
+    type: Boolean,
+  },
   partner: {
-    type: Object,
     default: null,
+    type: Object,
   },
   unitId: {
-    type: Number,
     default: null,
-  },
-  offlineMode: {
-    type: Boolean,
-    default: false,
+    type: Number,
   },
 });
 
-const { inputRules } = useInputRules();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const formRef = ref(null);
+const { inputRules } = useInputRules();
+
 const avatarRef = ref(null);
-const stateSelectRef = ref(null);
 const citySelectRef = ref(null);
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+const stateSelectRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form, getFormAsFormData, getUpdatedFieldsAsFormData } = useForm(
+  {
+    address_number: partner?.address_number ?? null,
+    birth_date: partner?.birth_date ?? null,
+    cell_number: partner?.cell_number ?? null,
+    city_id: partner?.city_id ?? null,
+    complement: partner?.complement ?? null,
+    cpf: partner?.cpf ?? null,
+    email: partner?.email ?? null,
+    name: partner?.name ?? null,
+    neighborhood: partner?.neighborhood ?? null,
+    participation: partner?.participation ?? null,
+    phone_number: partner?.phone_number ?? null,
+    postal_code: partner?.postal_code ?? null,
+    rg: partner?.rg ?? null,
+    role: partner?.role ?? null,
+    secondary_email: partner?.secondary_email ?? null,
+    social_name: partner?.social_name ?? null,
+    state_id: partner?.state_id ?? null,
+    street: partner?.street ?? null,
+    unit_id: unitId,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
+});
 
-const selectedState = ref(null);
-const selectedCity = ref(null);
 const avatarChanged = ref(false);
 const avatarFile = ref(null);
 
-// Exibe DD/MM/YYYY para o usuário; form.birth_date armazena YYYY-MM-DD para o backend
 const birthDateDisplay = ref(
   partner?.birth_date ? formatDateYMDtoDMY(partner.birth_date) : null,
 );
 
-watch(birthDateDisplay, (val) => {
-  try {
-    form.birth_date = val?.length === 10 ? formatDateDMYtoYMD(val) : null;
-  } catch {
-    form.birth_date = null;
-  }
-});
-
-const { form, getFormAsFormData } = useFormUpdateTracker({
-  unit_id: unitId,
-  name: partner?.name ?? null,
-  social_name: partner?.social_name ?? null,
-  role: partner?.role ?? null,
-  cpf: partner?.cpf ?? null,
-  rg: partner?.rg ?? null,
-  birth_date: partner?.birth_date ?? null,
-  participation: partner?.participation ?? null,
-  email: partner?.email ?? null,
-  secondary_email: partner?.secondary_email ?? null,
-  phone_number: partner?.phone_number ?? null,
-  cell_number: partner?.cell_number ?? null,
-  postal_code: partner?.postal_code ?? null,
-  street: partner?.street ?? null,
-  address_number: partner?.address_number ?? null,
-  neighborhood: partner?.neighborhood ?? null,
-  complement: partner?.complement ?? null,
-  city_id: partner?.city_id ?? null,
-  state_id: partner?.state_id ?? null,
-});
-
-watch(selectedState, (state) => {
-  form.state_id = state?.value ?? null;
-});
-
-watch(selectedCity, (city) => {
-  form.city_id = city?.value ?? null;
-});
+const selectedCity = ref(null);
+const selectedState = ref(null);
 
-function onAvatarChange(file) {
+const onAvatarChange = (file) => {
   avatarChanged.value = true;
   avatarFile.value = file;
-}
+};
 
-const { loading, validationErrors, execute } = useSubmitHandler({
-  formRef,
-  onSuccess: (result) => onDialogOK(result),
-});
-
-async function onOKClick() {
+const onOKClick = async () => {
   if (offlineMode) {
-    const partnerData = { ...form };
+    const partnerData = {
+      ...form,
+    };
+
     if (avatarFile.value instanceof File) {
       if (partner?.avatar_url?.startsWith("blob:")) {
         URL.revokeObjectURL(partner.avatar_url);
       }
+
       partnerData.avatar = avatarFile.value;
       partnerData.avatar_url = URL.createObjectURL(avatarFile.value);
     } else if (partner?.avatar_url) {
       partnerData.avatar_url = partner.avatar_url;
     }
+
     onDialogOK(partnerData);
+
     return;
   }
 
   await execute(() => {
-    const formData = getFormAsFormData();
+    const formData = partner
+      ? getUpdatedFieldsAsFormData()
+      : getFormAsFormData();
 
     if (avatarChanged.value) {
       formData.append("avatar", avatarFile.value ?? "");
@@ -353,7 +371,24 @@ async function onOKClick() {
 
     return createPartner(formData);
   });
-}
+};
+
+watch(birthDateDisplay, (value) => {
+  try {
+    form.birth_date =
+      value?.length === 10 ? formatDateDMYtoYMD(value) : null;
+  } catch {
+    form.birth_date = null;
+  }
+});
+
+watch(selectedCity, (city) => {
+  form.city_id = city?.value ?? null;
+});
+
+watch(selectedState, (state) => {
+  form.state_id = state?.value ?? null;
+});
 
 onMounted(() => {
   if (!partner) return;
@@ -361,11 +396,13 @@ onMounted(() => {
   if (partner.avatar_url) {
     avatarRef.value?.setImageUrl(partner.avatar_url);
   }
+
   if (partner.state_id) {
     stateSelectRef.value?.selectStateById(partner.state_id);
   }
+
   if (partner.city_id) {
     citySelectRef.value?.selectCityById(partner.city_id);
   }
 });
-</script>
+</script>

+ 70 - 46
src/pages/unit/components/AddMediaDialog.vue

@@ -7,53 +7,54 @@
       <DefaultDialogHeader title="Adicionar Mídia" @close="onDialogCancel" />
 
       <DefaultForm ref="formRef" @submit="onOKClick">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section class="q-pt-none">
-            <div class="column q-gutter-sm">
-              <DefaultInput
-                v-model="form.title"
-                v-model:error="validationErrors.title"
-                :error-message="validationErrors.title"
-                label="Título"
-                outlined
-                :rules="[inputRules.required]"
-              />
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <DefaultInput
+              v-model="form.title"
+              v-model:error="validationErrors.title"
+              class="col-12"
+              label="Título"
+              outlined
+              :rules="[inputRules.required]"
+            />
 
-              <q-file
-                v-model="selectedFile"
-                :error="!!validationErrors.file"
-                :error-message="validationErrors.file"
-                class="required-field"
-                label="Arquivo"
-                outlined
-                accept="image/*,video/*,.pdf"
-                :rules="[inputRules.required]"
-              >
-                <template #prepend>
-                  <q-icon name="attach_file" />
-                </template>
-              </q-file>
+            <DefaultMediaPicker
+              v-model="form.file"
+              v-model:error="validationErrors.file"
+              accept="image/*,video/*,.pdf"
+              class="col-12"
+              label="Arquivo"
+              :label-outside="false"
+              :rules="[inputRules.required]"
+            />
 
+            <div class="col-12">
               <q-toggle
                 v-model="form.visible_to_franchisee"
-                label="Visível ao franqueado"
                 color="positive"
+                label="Visível ao franqueado"
               />
             </div>
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right" class="q-pa-md">
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
-            outline
-            color="primary"
+            color="negative"
             label="Cancelar"
+            no-caps
+            outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            color="primary-2"
+            color="primary"
             label="Adicionar"
+            no-caps
             type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -63,48 +64,71 @@
 </template>
 
 <script setup>
+import { createMedia } from "src/api/unit_media";
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createMedia } from "src/api/unit_media";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultMediaPicker from "src/components/defaults/DefaultMediaPicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { unitId } = defineProps({
-  unitId: { type: Number, required: true },
+  unitId: {
+    required: true,
+    type: Number,
+  },
 });
 
-const { inputRules } = useInputRules();
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
+const { inputRules } = useInputRules();
+
 const formRef = ref(null);
-const selectedFile = ref(null);
+const scrollAreaRef = ref(null);
 
-const form = ref({
-  title: "",
-  visible_to_franchisee: false,
-});
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    file: null,
+    title: "",
+    visible_to_franchisee: false,
+  },
+  { containerRef: scrollAreaRef },
+);
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
   formRef,
   onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
 });
 
-async function onOKClick() {
+const onOKClick = async () => {
   await execute(() => {
     const formData = new FormData();
+
+    formData.append("file", form.file);
+    formData.append("title", form.title);
     formData.append("unit_id", unitId);
-    formData.append("title", form.value.title);
-    formData.append("file", selectedFile.value);
+
     formData.append(
       "visible_to_franchisee",
-      form.value.visible_to_franchisee ? 1 : 0,
+      form.visible_to_franchisee ? 1 : 0,
     );
+
     return createMedia(formData);
   });
-}
-</script>
+};
+</script>

+ 327 - 239
src/pages/unit/components/CreateContractDialog.vue

@@ -4,302 +4,390 @@
       class="q-dialog-plugin overflow-hidden"
       style="width: 100%; max-width: 1100px"
     >
-      <DefaultDialogHeader
-        :title="'Criar novo contrato'"
-        @close="onDialogCancel"
-      />
-
-      <q-card-section>
-        <div class="text-body2 q-mb-sm">Dados da Unidade</div>
-
-        <div class="row q-col-gutter-sm">
-          <DefaultInput
-            :model-value="unitData.name"
-            label="Nome da Unidade"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-6 col-12"
-            readonly
+      <DefaultDialogHeader title="Criar novo contrato" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit="onSubmit">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-body2">
+              Dados da Unidade
+            </div>
+
+            <DefaultInput
+              class="col-12 col-md-6"
+              color="secondary"
+              label="Nome da Unidade"
+              label-color="secondary"
+              readonly
+              :model-value="unitData.name"
+            />
+
+            <DefaultInput
+              class="col-12 col-md-3"
+              color="secondary"
+              label="CNPJ"
+              label-color="secondary"
+              readonly
+              :mask="masks.Brasil.cnpj"
+              :model-value="unitData.cnpj"
+              :rules="[inputRules.cnpj]"
+            />
+
+            <DefaultInput
+              class="col-12 col-md-3"
+              color="secondary"
+              disable
+              label="ID"
+              label-color="secondary"
+              :model-value="unitData.id"
+            />
+
+            <DefaultInput
+              class="col-12 col-md-4"
+              color="secondary"
+              disable
+              label="Nome do Franqueado"
+              label-color="secondary"
+              :model-value="unitData.franchisee_name"
+            />
+
+            <DefaultInput
+              class="col-12 col-md-4"
+              color="secondary"
+              disable
+              label="CPF"
+              label-color="secondary"
+              :mask="masks.Brasil.cpf"
+              :model-value="unitData.franchisee_document"
+              :rules="[inputRules.cpf]"
+            />
+
+            <DefaultInput
+              class="col-12 col-md-4"
+              color="secondary"
+              disable
+              label="Data de Nascimento"
+              label-color="secondary"
+              :model-value="unitData.franchisee_birthday"
+            />
+
+            <div class="col-12 text-body2 q-mt-sm">
+              Definir Valores e TBR
+            </div>
+
+            <DefaultInputDatePicker
+              v-model:untreated-date="form.start_date"
+              v-model:error="validationErrors.start_date"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="Data de Início"
+              label-color="secondary"
+              :error-message="validationErrors.start_date"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInputDatePicker
+              v-model:untreated-date="form.end_date"
+              v-model:error="validationErrors.end_date"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="Data de Fim"
+              label-color="secondary"
+              :error-message="validationErrors.end_date"
+              :rules="[inputRules.required, endDateRule]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.tbr_fixed_value"
+              v-model:error="validationErrors.tbr_fixed_value"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="TBR $"
+              label-color="secondary"
+              :error-message="validationErrors.tbr_fixed_value"
+              :rules="[requiredNumberRule]"
+            />
+
+            <DefaultInput
+              v-model="form.invoice_due_date"
+              v-model:error="validationErrors.invoice_due_date"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="Dia de Vencimento do Boleto"
+              label-color="secondary"
+              max="31"
+              min="1"
+              type="number"
+              :error-message="validationErrors.invoice_due_date"
+              :rules="[invoiceDueDateRule]"
+            />
+
+            <DefaultSelect
+              v-model="form.municipality_size_id"
+              v-model:error="validationErrors.municipality_size_id"
+              class="col-12 col-md-3"
+              color="secondary"
+              emit-value
+              fill-input
+              hide-selected
+              input-debounce="0"
+              label="Faixa de Habitante"
+              label-color="secondary"
+              map-options
+              use-input
+              :error-message="validationErrors.municipality_size_id"
+              :options="municipalitySizeOptions"
+            />
+
+            <DefaultInput
+              v-model="form.tax_base_royalts"
+              v-model:error="validationErrors.tbr_fixed_value_percentage"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="Taxa Base Royalties"
+              label-color="secondary"
+              max="100"
+              min="0"
+              type="number"
+              :error-message="validationErrors.tbr_fixed_value_percentage"
+              :rules="percentageRules"
+            >
+              <template #append>
+                <span class="text-secondary">%</span>
+              </template>
+            </DefaultInput>
+
+            <DefaultInput
+              v-model="form.tax_base_fnm"
+              v-model:error="validationErrors.marketing_fund_percentage"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="Taxa Base FMN"
+              label-color="secondary"
+              max="100"
+              min="0"
+              type="number"
+              :error-message="validationErrors.marketing_fund_percentage"
+              :rules="percentageRules"
+            >
+              <template #append>
+                <span class="text-secondary">%</span>
+              </template>
+            </DefaultInput>
+
+            <DefaultInput
+              v-model="form.tax_base_maintenance"
+              v-model:error="validationErrors.maintance_tax_percentage"
+              class="col-12 col-md-3"
+              color="secondary"
+              label="Taxa Base Manutenção"
+              label-color="secondary"
+              max="100"
+              min="0"
+              type="number"
+              :error-message="validationErrors.maintance_tax_percentage"
+              :rules="percentageRules"
+            >
+              <template #append>
+                <span class="text-secondary">%</span>
+              </template>
+            </DefaultInput>
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
           />
 
-          <DefaultInput
-            :model-value="unitData.cnpj"
-            label="CNPJ"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-3 col-12"
-            :mask="masks.Brasil.cnpj"
-            :rules="[inputRules.cnpj]"
-            readonly
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
           />
-
-          <DefaultInput
-            :model-value="unitData.id"
-            label="ID"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-3 col-12"
-            disable
-          />
-
-          <DefaultInput
-            :model-value="unitData.franchisee_name"
-            label="Nome do Franqueado"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-4 col-12"
-            disable
-          />
-
-          <DefaultInput
-            :model-value="unitData.franchisee_document"
-            label="CPF"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-4 col-12"
-            :mask="masks.Brasil.cpf"
-            :rules="[inputRules.cpf]"
-            disable
-          />
-
-          <DefaultInput
-            :model-value="unitData.franchisee_birthday"
-            label="Data de Nascimento"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-4 col-12"
-            disable
-          />
-        </div>
-      </q-card-section>
-
-      <q-card-section>
-        <div class="text-body2 q-mb-sm">Definir Valores e TBR</div>
-
-        <div class="row q-col-gutter-sm">
-          <DefaultInputDatePicker
-            v-model:untreated-date="contractForm.start_date"
-            label="Data de Início"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-3 col-12"
-          />
-
-          <DefaultInputDatePicker
-            v-model:untreated-date="contractForm.end_date"
-            label="Data de Fim"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-3 col-12"
-          />
-
-          <DefaultCurrencyInput
-            v-model="contractForm.tbr_fixed_value"
-            label="TBR $"
-            color="secondary"
-            label-color="secondary"
-            class="col-md-3 col-12"
-          />
-
-          <DefaultInput
-            v-model="contractForm.invoice_due_date"
-            label="Dia de Vencimento do Boleto"
-            color="secondary"
-            label-color="secondary"
-            type="number"
-            class="col-md-3 col-12"
-          >
-          </DefaultInput>
-
-          <DefaultSelect
-            v-model="contractForm.municipality_size_id"
-            label="Faixa de Habitante"
-            color="secondary"
-            label-color="secondary"
-            :options="municipalitySizeOptions"
-            emit-value
-            map-options
-            use-input
-            fill-input
-            hide-selected
-            input-debounce="0"
-            class="col-md-3 col-12"
-          />
-
-          <DefaultInput
-            v-model="contractForm.tax_base_royalts"
-            label="Taxa Base Royalties"
-            color="secondary"
-            label-color="secondary"
-            type="number"
-            class="col-md-3 col-12"
-          >
-            <template #append>
-              <span class="text-secondary">%</span>
-            </template>
-          </DefaultInput>
-
-          <DefaultInput
-            v-model="contractForm.tax_base_fnm"
-            label="Taxa Base FMN"
-            color="secondary"
-            label-color="secondary"
-            type="number"
-            class="col-md-3 col-12"
-          >
-            <template #append>
-              <span class="text-secondary">%</span>
-            </template>
-          </DefaultInput>
-
-          <DefaultInput
-            v-model="contractForm.tax_base_maintenance"
-            label="Taxa Base Manutenção"
-            color="secondary"
-            label-color="secondary"
-            type="number"
-            class="col-md-3 col-12"
-          >
-            <template #append>
-              <span class="text-secondary">%</span>
-            </template>
-          </DefaultInput>
-        </div>
-      </q-card-section>
-
-      <q-card-actions align="right">
-        <q-btn
-          outline
-          color="primary"
-          label="Cancelar"
-          @click="onDialogCancel"
-        />
-        <q-btn color="primary" label="Salvar" :loading="saving" @click="save" />
-      </q-card-actions>
+        </q-card-actions>
+      </DefaultForm>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, reactive, onMounted } from "vue";
+import { createFranchiseeContract } from "src/api/franchisee_contract";
+import { getLatestTbr } from "src/api/tbr";
+import { getMunicipalitySizes } from "src/api/tbr_calculation";
+import { getUnit } from "src/api/unit";
+import { onMounted, reactive, ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import masks from "src/helpers/masks";
 
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import masks from "src/helpers/masks";
-import { useInputRules } from "src/composables/useInputRules";
-
-import { getUnit } from "src/api/unit";
-import { getLatestTbr } from "src/api/tbr";
-import { getMunicipalitySizes } from "src/api/tbr_calculation";
-import { createFranchiseeContract } from "src/api/franchisee_contract";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const props = defineProps({
   unitId: {
-    type: Number,
     required: true,
+    type: Number,
   },
 });
 
-const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
-const saving = ref(false);
-const municipalitySizeOptions = ref([]);
 const { inputRules } = useInputRules();
 
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    end_date: null,
+    invoice_due_date: null,
+    municipality_size_id: null,
+    start_date: null,
+    tax_base_fnm: null,
+    tax_base_maintenance: null,
+    tax_base_royalts: null,
+    tbr_fixed_value: null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
+});
+
 const unitData = reactive({
-  id: null,
-  name: null,
   cnpj: null,
-  franchisee_name: null,
-  franchisee_document: null,
   franchisee_birthday: null,
+  franchisee_document: null,
+  franchisee_name: null,
+  id: null,
+  name: null,
 });
 
-const contractForm = reactive({
-  start_date: null,
-  end_date: null,
-  tbr_fixed_value: null,
-  invoice_due_date: null,
-  municipality_size_id: null,
-  tax_base_royalts: null,
-  tax_base_fnm: null,
-  tax_base_maintenance: null,
-});
+const municipalitySizeOptions = ref([]);
 
-async function loadData() {
+const percentageRules = [
+  inputRules.minValue(0),
+  inputRules.maxValue(100),
+];
+
+const endDateRule = (value) =>
+  !value ||
+  !form.start_date ||
+  value > form.start_date ||
+  "A data de fim deve ser posterior à data de início";
+
+const invoiceDueDateRule = (value) =>
+  value === null ||
+  value === undefined ||
+  value === "" ||
+  (Number.isInteger(Number(value)) &&
+    Number(value) >= 1 &&
+    Number(value) <= 31) ||
+  "O dia de vencimento deve ser um número inteiro entre 1 e 31";
+
+const loadData = async () => {
   const [unit, latestTbr, sizes] = await Promise.all([
     getUnit(props.unitId),
     getLatestTbr(),
     getMunicipalitySizes(),
   ]);
 
-  unitData.id = unit.id;
-  unitData.name = unit.name;
   unitData.cnpj = unit.cnpj;
   unitData.franchisee_name = unit.name_responsible;
+  unitData.id = unit.id;
+  unitData.name = unit.name;
 
   const firstPartner = unit.partners?.[0];
+
   if (firstPartner) {
-    unitData.franchisee_document = firstPartner.cpf;
     unitData.franchisee_birthday = firstPartner.birth_date;
+    unitData.franchisee_document = firstPartner.cpf;
   }
 
   if (latestTbr) {
-    contractForm.tbr_fixed_value = parseFloat(latestTbr.tbr_value);
-    contractForm.tax_base_royalts = parseFloat(
-      (latestTbr.royalties_percentage * 100).toFixed(4),
-    );
-    contractForm.tax_base_fnm = parseFloat(
+    form.tax_base_fnm = parseFloat(
       (latestTbr.fnm_percentage * 100).toFixed(4),
     );
-    contractForm.tax_base_maintenance = parseFloat(
+
+    form.tax_base_maintenance = parseFloat(
       (latestTbr.maintenance_percentage * 100).toFixed(4),
     );
+
+    form.tax_base_royalts = parseFloat(
+      (latestTbr.royalties_percentage * 100).toFixed(4),
+    );
+
+    form.tbr_fixed_value = parseFloat(latestTbr.tbr_value);
   }
 
-  municipalitySizeOptions.value = (sizes ?? []).map((s) => ({
-    label: s.description,
-    value: s.id,
+  municipalitySizeOptions.value = (sizes ?? []).map((size) => ({
+    label: size.description,
+    value: size.id,
   }));
-}
+};
+
+const onSubmit = async () => {
+  await execute(() =>
+    createFranchiseeContract({
+      end_date: form.end_date,
+      invoice_due_date: form.invoice_due_date,
 
-async function save() {
-  saving.value = true;
-  try {
-    await createFranchiseeContract({
-      unit_id: props.unitId,
-      start_date: contractForm.start_date,
-      end_date: contractForm.end_date,
-      tbr_fixed_value: contractForm.tbr_fixed_value,
-      invoice_due_date: contractForm.invoice_due_date,
-      municipality_size_id: contractForm.municipality_size_id,
-      tbr_fixed_value_percentage:
-        contractForm.tax_base_royalts != null
-          ? contractForm.tax_base_royalts / 100
-          : null,
-      marketing_fund_percentage:
-        contractForm.tax_base_fnm != null
-          ? contractForm.tax_base_fnm / 100
-          : null,
       maintance_tax_percentage:
-        contractForm.tax_base_maintenance != null
-          ? contractForm.tax_base_maintenance / 100
+        form.tax_base_maintenance != null
+          ? form.tax_base_maintenance / 100
           : null,
-    });
-    onDialogOK();
-  } catch (error) {
-    console.error(error);
-  } finally {
-    saving.value = false;
-  }
-}
+
+      marketing_fund_percentage:
+        form.tax_base_fnm != null ? form.tax_base_fnm / 100 : null,
+
+      municipality_size_id: form.municipality_size_id,
+      start_date: form.start_date,
+      tbr_fixed_value: form.tbr_fixed_value,
+
+      tbr_fixed_value_percentage:
+        form.tax_base_royalts != null ? form.tax_base_royalts / 100 : null,
+
+      unit_id: props.unitId,
+    }),
+  );
+};
+
+const requiredNumberRule = (value) =>
+  (value !== null && value !== undefined && value !== "") ||
+  inputRules.required(null);
+
+requiredNumberRule.$id = "required";
 
 onMounted(loadData);
-</script>
+</script>

+ 295 - 235
src/pages/unit/tabs/FinancialTab.vue

@@ -1,210 +1,283 @@
 <template>
-  <div class="q-pa-md">
-    <q-scroll-area ref="scrollAreaRef" class="unit-form-tab-scroll-area">
-      <DefaultForm ref="formRef" class="column q-gutter-lg" @submit="onSave">
-    <!-- Dados Bancários -->
-    <div>
-      <div class="text-subtitle1 text-weight-medium q-mb-sm">
-        Dados Bancários
-      </div>
+  <div class="financial-tab q-pa-md">
+    <DefaultForm ref="formRef" class="unit-tab-form" @submit="onSave">
+      <q-scroll-area ref="scrollAreaRef" class="unit-form-tab-scroll-area">
+        <div class="column q-gutter-lg">
+          <div>
+            <div class="text-subtitle1 text-weight-medium q-mb-sm">
+              Dados Bancários
+            </div>
 
-      <div class="row q-col-gutter-sm">
-        <DefaultInput
-          v-model="form.account_holder"
-          v-model:error="validationErrors.account_holder"
-          :error-message="validationErrors.account_holder"
-          label="Titular da Conta"
-          class="col-12 col-md-4"
-          outlined
-        />
-        <DefaultInput
-          v-model="form.agency"
-          v-model:error="validationErrors.agency"
-          :error-message="validationErrors.agency"
-          label="Agência"
-          class="col-12 col-md-4"
-          outlined
-        />
-        <DefaultInput
-          v-model="form.account"
-          v-model:error="validationErrors.account"
-          :error-message="validationErrors.account"
-          label="Conta"
-          class="col-12 col-md-4"
-          outlined
-        />
+            <div class="row q-col-gutter-md">
+              <DefaultInput
+                v-model="form.account_holder"
+                v-model:error="validationErrors.account_holder"
+                class="col-12 col-md-4"
+                label="Titular da Conta"
+                outlined
+                :error-message="validationErrors.account_holder"
+              />
 
-        <DefaultInput
-          v-model="form.bank"
-          v-model:error="validationErrors.bank"
-          :error-message="validationErrors.bank"
-          label="Banco"
-          class="col-12 col-md-3"
-          outlined
-        />
-        <DefaultInput
-          v-model="form.pix_key"
-          v-model:error="validationErrors.pix_key"
-          :error-message="validationErrors.pix_key"
-          label="Chave Pix"
-          class="col-12 col-md-3"
-          outlined
-        />
-        <DefaultSelect
-          v-model="form.tax_regime"
-          v-model:error="validationErrors.tax_regime"
-          :error-message="validationErrors.tax_regime"
-          label="Regime Tributário"
-          :options="taxRegimeOptions"
-          class="col-12 col-md-3"
-          outlined
-          emit-value
-          map-options
-        />
-        <DefaultSelect
-          v-model="form.account_type"
-          v-model:error="validationErrors.account_type"
-          :error-message="validationErrors.account_type"
-          label="Tipo de Conta"
-          :options="accountTypeOptions"
-          class="col-12 col-md-3"
-          outlined
-          emit-value
-          map-options
-        />
-      </div>
-    </div>
+              <DefaultInput
+                v-model="form.agency"
+                v-model:error="validationErrors.agency"
+                class="col-12 col-md-4"
+                label="Agência"
+                outlined
+                :error-message="validationErrors.agency"
+              />
 
-    <!-- Dados para Faturamento -->
-    <div>
-      <div class="text-subtitle1 text-weight-medium q-mb-sm">
-        Dados para Faturamento
-      </div>
+              <DefaultInput
+                v-model="form.account"
+                v-model:error="validationErrors.account"
+                class="col-12 col-md-4"
+                label="Conta"
+                outlined
+                :error-message="validationErrors.account"
+              />
 
-      <div class="row q-col-gutter-sm">
-        <DefaultSelect
-          v-model="form.billing_method"
-          v-model:error="validationErrors.billing_method"
-          :error-message="validationErrors.billing_method"
-          label="Forma de Cobrança"
-          :options="billingMethodOptions"
-          class="col-12 col-md-6"
-          outlined
-          emit-value
-          map-options
-        />
-        <DefaultInputDatePicker
-          v-model="form.due_date"
-          v-model:error="validationErrors.due_date"
-          :error-message="validationErrors.due_date"
-          label="Data de Vencimento"
-          class="col-12 col-md-6"
-        />
-        <DefaultInput
-          v-model="form.financial_email"
-          v-model:error="validationErrors.financial_email"
-          :error-message="validationErrors.financial_email"
-          label="E-mail Financeiro"
-          class="col-12"
-          outlined
-          :rules="[inputRules.email]"
-        />
-      </div>
-    </div>
+              <DefaultInput
+                v-model="form.bank"
+                v-model:error="validationErrors.bank"
+                class="col-12 col-md-3"
+                label="Banco"
+                outlined
+                :error-message="validationErrors.bank"
+              />
 
-    <!-- Contratos -->
-    <div>
-      <div class="text-subtitle1 text-weight-medium q-mb-sm">
-        Contratos
-      </div>
+              <DefaultInput
+                v-model="form.pix_key"
+                v-model:error="validationErrors.pix_key"
+                class="col-12 col-md-3"
+                label="Chave Pix"
+                outlined
+                :error-message="validationErrors.pix_key"
+              />
 
-      <div class="row q-col-gutter-sm">
-        <DefaultInput
-          v-model.number="form.max_freeze_count"
-          v-model:error="validationErrors.max_freeze_count"
-          :error-message="validationErrors.max_freeze_count"
-          label="Máximo de Meses para Trancar"
-          type="number"
-          class="col-12 col-md-4"
-          outlined
-          :rules="[inputRules.minValue(0)]"
-        />
-        <DefaultInput
-          v-model.number="form.default_discount"
-          v-model:error="validationErrors.default_discount"
-          :error-message="validationErrors.default_discount"
-          label="Desconto Padrão (%)"
-          type="number"
-          class="col-12 col-md-4"
-          outlined
-          :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
-        />
-        <DefaultInput
-          v-model.number="form.default_interest"
-          v-model:error="validationErrors.default_interest"
-          :error-message="validationErrors.default_interest"
-          label="Juros Padrão (% a.m.)"
-          type="number"
-          class="col-12 col-md-4"
-          outlined
-          :rules="[inputRules.minValue(0)]"
+              <DefaultSelect
+                v-model="form.tax_regime"
+                v-model:error="validationErrors.tax_regime"
+                class="col-12 col-md-3"
+                emit-value
+                label="Regime Tributário"
+                map-options
+                outlined
+                :error-message="validationErrors.tax_regime"
+                :options="taxRegimeOptions"
+              />
+
+              <DefaultSelect
+                v-model="form.account_type"
+                v-model:error="validationErrors.account_type"
+                class="col-12 col-md-3"
+                emit-value
+                label="Tipo de Conta"
+                map-options
+                outlined
+                :error-message="validationErrors.account_type"
+                :options="accountTypeOptions"
+              />
+            </div>
+          </div>
+
+          <div>
+            <div class="text-subtitle1 text-weight-medium q-mb-sm">
+              Dados para Faturamento
+            </div>
+
+            <div class="row q-col-gutter-md">
+              <DefaultSelect
+                v-model="form.billing_method"
+                v-model:error="validationErrors.billing_method"
+                class="col-12 col-md-6"
+                emit-value
+                label="Forma de Cobrança"
+                map-options
+                outlined
+                :error-message="validationErrors.billing_method"
+                :options="billingMethodOptions"
+              />
+
+              <DefaultInputDatePicker
+                v-model="form.due_date"
+                v-model:error="validationErrors.due_date"
+                class="col-12 col-md-6"
+                label="Data de Vencimento"
+                :error-message="validationErrors.due_date"
+              />
+
+              <DefaultInput
+                v-model="form.financial_email"
+                v-model:error="validationErrors.financial_email"
+                class="col-12"
+                label="E-mail Financeiro"
+                outlined
+                :error-message="validationErrors.financial_email"
+                :rules="[inputRules.email]"
+              />
+            </div>
+          </div>
+
+          <div>
+            <div class="text-subtitle1 text-weight-medium q-mb-sm">
+              Contratos
+            </div>
+
+            <div class="row q-col-gutter-md">
+              <DefaultInput
+                v-model.number="form.max_freeze_count"
+                v-model:error="validationErrors.max_freeze_count"
+                class="col-12 col-md-4"
+                label="Máximo de Meses para Trancar"
+                outlined
+                type="number"
+                :error-message="validationErrors.max_freeze_count"
+                :rules="[inputRules.minValue(0)]"
+              />
+
+              <DefaultInput
+                v-model.number="form.default_discount"
+                v-model:error="validationErrors.default_discount"
+                class="col-12 col-md-4"
+                label="Desconto Padrão (%)"
+                outlined
+                type="number"
+                :error-message="validationErrors.default_discount"
+                :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
+              />
+
+              <DefaultInput
+                v-model.number="form.default_interest"
+                v-model:error="validationErrors.default_interest"
+                class="col-12 col-md-4"
+                label="Juros Padrão (% a.m.)"
+                outlined
+                type="number"
+                :error-message="validationErrors.default_interest"
+                :rules="[inputRules.minValue(0)]"
+              />
+
+              <DefaultInput
+                v-model.number="form.default_fine"
+                v-model:error="validationErrors.default_fine"
+                class="col-12 col-md-4"
+                label="Multa Padrão (%)"
+                outlined
+                type="number"
+                :error-message="validationErrors.default_fine"
+                :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
+              />
+
+              <div class="col-12 col-md-4 flex items-center">
+                <q-toggle
+                  v-model="form.charge_roi"
+                  color="primary"
+                  label="Cobrar ROI"
+                />
+              </div>
+
+              <div class="col-12 col-md-4 flex items-center">
+                <q-toggle
+                  v-model="form.charge_fnm"
+                  color="primary"
+                  label="Cobrar FNM"
+                />
+              </div>
+            </div>
+          </div>
+        </div>
+      </q-scroll-area>
+
+      <q-separator />
+
+      <div class="row justify-end q-gutter-sm q-pt-md">
+        <q-btn
+          color="negative"
+          label="Cancelar"
+          no-caps
+          outline
+          @click="resetForm"
         />
-        <DefaultInput
-          v-model.number="form.default_fine"
-          v-model:error="validationErrors.default_fine"
-          :error-message="validationErrors.default_fine"
-          label="Multa Padrão (%)"
-          type="number"
-          class="col-12 col-md-4"
-          outlined
-          :rules="[inputRules.minValue(0), inputRules.maxValue(100)]"
+
+        <q-btn
+          color="primary"
+          label="Salvar"
+          no-caps
+          type="submit"
+          unelevated
+          :loading="loading"
         />
-        <div class="col-12 col-md-4 flex items-center">
-          <q-toggle v-model="form.charge_roi" label="Cobrar ROI" color="primary" />
-        </div>
-        <div class="col-12 col-md-4 flex items-center">
-          <q-toggle v-model="form.charge_fnm" label="Cobrar FNM" color="primary" />
-        </div>
       </div>
-    </div>
-
-    <!-- Actions -->
-    <div class="row justify-end q-gutter-sm">
-      <q-btn label="Cancelar" color="primary" outline @click="resetForm" />
-      <q-btn
-        label="Salvar"
-        color="primary-2"
-        type="submit"
-        :loading="loading"
-      />
-    </div>
-      </DefaultForm>
-    </q-scroll-area>
+    </DefaultForm>
   </div>
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
-import { getFinancialByUnit, upsertFinancial } from "src/api/unit_financial";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  getFinancialByUnit,
+  upsertFinancial,
+} from "src/api/unit_financial";
+
+import { onMounted, ref } from "vue";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
 import { useScroll } from "src/composables/useScroll";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
 const props = defineProps({
-  unitId: { type: Number, default: null },
+  unitId: {
+    default: null,
+    type: Number,
+  },
 });
 
-const taxRegimeOptions = [
-  { label: "Selecione", value: null },
-  { label: "Simples Nacional", value: "simples_nacional" },
-  { label: "Lucro Presumido", value: "lucro_presumido" },
-  { label: "Lucro Real", value: "lucro_real" },
-  { label: "MEI", value: "mei" },
-];
+const { inputRules } = useInputRules();
+
+const { scrollToComponent } = useScroll();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { form, getUpdatedFields, resetUpdateForm, setUpdateFormAsOriginal } =
+  useForm(
+    {
+      account: null,
+      account_holder: null,
+      account_type: null,
+      agency: null,
+      bank: null,
+      billing_method: null,
+      charge_fnm: true,
+      charge_roi: true,
+      default_discount: null,
+      default_fine: null,
+      default_interest: null,
+      due_date: null,
+      financial_email: null,
+      max_freeze_count: null,
+      pix_key: null,
+      tax_regime: null,
+    },
+    { containerRef: scrollAreaRef },
+  );
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (saved) => {
+    Object.assign(form, saved);
+    setUpdateFormAsOriginal();
+  },
+  scrollFn: scrollToComponent,
+});
 
 const accountTypeOptions = [
   { label: "Selecione", value: null },
@@ -220,79 +293,66 @@ const billingMethodOptions = [
   { label: "Cartão de Crédito", value: "credit_card" },
 ];
 
-const defaultForm = () => ({
-  tax_regime: null,
-  bank: null,
-  agency: null,
-  account: null,
-  account_type: null,
-  account_holder: null,
-  pix_key: null,
-  billing_method: null,
-  due_date: null,
-  financial_email: null,
-  max_freeze_count: null,
-  charge_roi: true,
-  charge_fnm: true,
-  default_discount: null,
-  default_interest: null,
-  default_fine: null,
-});
-
-const formRef = ref(null);
-const scrollAreaRef = ref(null);
-const { inputRules } = useInputRules();
-const { scrollToComponent } = useScroll();
-const { form, getUpdatedFields, resetUpdateForm, setUpdateFormAsOriginal } =
-  useFormUpdateTracker(defaultForm(), { containerRef: scrollAreaRef });
+const taxRegimeOptions = [
+  { label: "Selecione", value: null },
+  { label: "Simples Nacional", value: "simples_nacional" },
+  { label: "Lucro Presumido", value: "lucro_presumido" },
+  { label: "Lucro Real", value: "lucro_real" },
+  { label: "MEI", value: "mei" },
+];
 
-async function fetchData() {
+const fetchData = async () => {
   if (!props.unitId) return;
+
   await loadFinancial();
-}
+};
 
-async function loadFinancial() {
+const loadFinancial = async () => {
   try {
     const data = await getFinancialByUnit(props.unitId);
+
     if (data) {
       Object.assign(form, data);
       setUpdateFormAsOriginal();
     }
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   }
-}
-
-const { loading, validationErrors, execute } = useSubmitHandler({
-  formRef,
-  containerRef: scrollAreaRef,
-  scrollFn: scrollToComponent,
-  onSuccess: (saved) => {
-    Object.assign(form, saved);
-    setUpdateFormAsOriginal();
-  },
-});
+};
 
-async function onSave() {
+const onSave = async () => {
   if (!props.unitId) return;
+
   await execute(() =>
     upsertFinancial({
       unit_id: props.unitId,
       ...getUpdatedFields.value,
     }),
   );
-}
+};
 
-function resetForm() {
+const resetForm = () => {
   resetUpdateForm();
-}
+};
 
 onMounted(fetchData);
 </script>
 
 <style scoped>
+.financial-tab {
+  box-sizing: border-box;
+  min-height: 0;
+}
+
+.unit-tab-form {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+}
+
 .unit-form-tab-scroll-area {
-  height: calc(100vh - 280px);
-  min-height: 320px;
+  flex: 1 1 auto;
+  min-height: 0;
 }
-</style>
+</style>

+ 332 - 275
src/pages/unit/tabs/UnitDataTab.vue

@@ -1,352 +1,409 @@
 <template>
-  <div class="q-pa-md">
-    <q-scroll-area ref="scrollAreaRef" class="unit-form-tab-scroll-area">
-      <DefaultForm ref="formRef">
-      <div class="column justify-center items-center q-mb-lg">
-        <AvatarImageComponent ref="avatarRef" @update:file="onAvatarChange" />
-
-        <div class="row full-width q-mt-md q-col-gutter-sm">
-          <DefaultInput
-            v-model="form.name"
-            v-model:error="validationErrors.name"
-            :error-message="validationErrors.name"
-            label="Nome da Unidade"
-            class="col-12"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultInput
-            v-model="form.social_reason"
-            v-model:error="validationErrors.social_reason"
-            :error-message="validationErrors.social_reason"
-            label="Razão Social"
-            class="col-12"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultInput
-            v-model="form.fantasy_name"
-            v-model:error="validationErrors.fantasy_name"
-            :error-message="validationErrors.fantasy_name"
-            label="Nome Fantasia"
-            class="col-12"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.cnpj"
-            v-model:error="validationErrors.cnpj"
-            :error-message="validationErrors.cnpj"
-            label="CNPJ"
-            class="col-4"
-            outlined
-            :mask="masks.Brasil.cnpj"
-            :rules="[inputRules.required, inputRules.cnpj]"
-          />
-
-          <DefaultInput
-            v-model="form.state_registration"
-            v-model:error="validationErrors.state_registration"
-            :error-message="validationErrors.state_registration"
-            label="Inscrição Estadual"
-            class="col-4"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.name_responsible"
-            v-model:error="validationErrors.name_responsible"
-            :error-message="validationErrors.name_responsible"
-            label="Franqueado Operador"
-            class="col-4"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultCepInput
-            v-model="form.postal_code"
-            v-model:error="validationErrors.postal_code"
-            :error-message="validationErrors.postal_code"
-            class="col-3"
-            outlined
-            :rules="[inputRules.required]"
-            @rua="form.street = $event"
-            @bairro="form.neighborhood = $event"
-            @uf="stateSelectRef?.selectStateByCode($event)"
-            @cidade="citySelectRef?.selectCityByName($event)"
-          />
-
-          <DefaultInput
-            v-model="form.street"
-            v-model:error="validationErrors.street"
-            :error-message="validationErrors.street"
-            label="Endereço"
-            class="col-6"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultInput
-            v-model="form.address_number"
-            v-model:error="validationErrors.address_number"
-            :error-message="validationErrors.address_number"
-            label="Número"
-            class="col-3"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultInput
-            v-model="form.neighborhood"
-            v-model:error="validationErrors.neighborhood"
-            :error-message="validationErrors.neighborhood"
-            label="Bairro"
-            class="col-4"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <StateSelect
-            ref="stateSelectRef"
-            v-model="selectedState"
-            v-model:error="validationErrors.state_id"
-            :error-message="validationErrors.state_id"
-            label="Estado"
-            class="col-4"
-            outlined
-            :rules="[inputRules.required]"
-          />
-
-          <CitySelect
-            ref="citySelectRef"
-            v-model="selectedCity"
-            v-model:error="validationErrors.city_id"
-            :error-message="validationErrors.city_id"
-            label="Cidade"
-            class="col-4"
-            outlined
-            :state="selectedState"
-            :rules="[inputRules.required]"
-          />
-
-          <DefaultInput
-            v-model="form.complement"
-            v-model:error="validationErrors.complement"
-            :error-message="validationErrors.complement"
-            label="Complemento"
-            class="col-12"
-            outlined
-          />
-
-          <DefaultInput
-            v-model="form.email"
-            v-model:error="validationErrors.email"
-            :error-message="validationErrors.email"
-            label="E-mail Principal"
-            class="col-6"
-            outlined
-            :rules="[inputRules.required, inputRules.email]"
-          />
-
-          <DefaultInput
-            v-model="form.secondary_email"
-            v-model:error="validationErrors.secondary_email"
-            :error-message="validationErrors.secondary_email"
-            label="E-mail Administrativo"
-            class="col-6"
-            outlined
-            :rules="[inputRules.email]"
-          />
-
-          <DefaultInput
-            v-model="form.phone_number"
-            v-model:error="validationErrors.phone_number"
-            :error-message="validationErrors.phone_number"
-            label="Telefone"
-            class="col-6"
-            outlined
-            :mask="masks.Brasil.telefone"
-          />
-
-          <DefaultInput
-            v-model="form.cell_number"
-            v-model:error="validationErrors.cell_number"
-            :error-message="validationErrors.cell_number"
-            label="Celular"
-            class="col-6"
-            outlined
-            :mask="masks.Brasil.celular"
-            :rules="[inputRules.required]"
-          />
-        </div>
+  <div class="unit-data-tab q-pa-md">
+    <DefaultForm ref="formRef" class="unit-tab-form" @submit="onSave">
+      <q-scroll-area ref="scrollAreaRef" class="unit-form-tab-scroll-area">
+        <div class="column justify-center items-center q-mb-lg">
+          <AvatarImageComponent ref="avatarRef" @update:file="onAvatarChange" />
+
+          <div class="row full-width q-mt-md q-col-gutter-md">
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              class="col-12"
+              label="Nome da Unidade"
+              outlined
+              :error-message="validationErrors.name"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.social_reason"
+              v-model:error="validationErrors.social_reason"
+              class="col-12"
+              label="Razão Social"
+              outlined
+              :error-message="validationErrors.social_reason"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.fantasy_name"
+              v-model:error="validationErrors.fantasy_name"
+              class="col-12"
+              label="Nome Fantasia"
+              outlined
+              :error-message="validationErrors.fantasy_name"
+            />
+
+            <DefaultInput
+              v-model="form.cnpj"
+              v-model:error="validationErrors.cnpj"
+              class="col-4"
+              label="CNPJ"
+              outlined
+              :error-message="validationErrors.cnpj"
+              :mask="masks.Brasil.cnpj"
+              :rules="[inputRules.required, inputRules.cnpj]"
+            />
+
+            <DefaultInput
+              v-model="form.state_registration"
+              v-model:error="validationErrors.state_registration"
+              class="col-4"
+              label="Inscrição Estadual"
+              outlined
+              :error-message="validationErrors.state_registration"
+            />
+
+            <DefaultInput
+              v-model="form.name_responsible"
+              v-model:error="validationErrors.name_responsible"
+              class="col-4"
+              label="Franqueado Operador"
+              outlined
+              :error-message="validationErrors.name_responsible"
+              :rules="[inputRules.required]"
+            />
 
-        <div class="row justify-end q-mt-md items-end full-width q-px-xs">
-          <div class="row q-gutter-sm">
-            <q-btn label="Cancelar" color="primary" outline @click="onCancel" />
-            <q-btn
-              label="Salvar"
-              color="primary-2"
-              :loading="loading"
-              @click="onSave"
+            <DefaultCepInput
+              v-model="form.postal_code"
+              v-model:error="validationErrors.postal_code"
+              class="col-3"
+              outlined
+              :error-message="validationErrors.postal_code"
+              :rules="[inputRules.required]"
+              @bairro="form.neighborhood = $event"
+              @cidade="citySelectRef?.selectCityByName($event)"
+              @rua="form.street = $event"
+              @uf="stateSelectRef?.selectStateByCode($event)"
+            />
+
+            <DefaultInput
+              v-model="form.street"
+              v-model:error="validationErrors.street"
+              class="col-6"
+              label="Endereço"
+              outlined
+              :error-message="validationErrors.street"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.address_number"
+              v-model:error="validationErrors.address_number"
+              class="col-3"
+              label="Número"
+              outlined
+              :error-message="validationErrors.address_number"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.neighborhood"
+              v-model:error="validationErrors.neighborhood"
+              class="col-4"
+              label="Bairro"
+              outlined
+              :error-message="validationErrors.neighborhood"
+              :rules="[inputRules.required]"
+            />
+
+            <StateSelect
+              ref="stateSelectRef"
+              v-model="selectedState"
+              v-model:error="validationErrors.state_id"
+              class="col-4"
+              label="Estado"
+              outlined
+              :error-message="validationErrors.state_id"
+              :rules="[inputRules.required]"
+            />
+
+            <CitySelect
+              ref="citySelectRef"
+              v-model="selectedCity"
+              v-model:error="validationErrors.city_id"
+              class="col-4"
+              label="Cidade"
+              outlined
+              :error-message="validationErrors.city_id"
+              :rules="[inputRules.required]"
+              :state="selectedState"
+            />
+
+            <DefaultInput
+              v-model="form.complement"
+              v-model:error="validationErrors.complement"
+              class="col-12"
+              label="Complemento"
+              outlined
+              :error-message="validationErrors.complement"
+            />
+
+            <DefaultInput
+              v-model="form.email"
+              v-model:error="validationErrors.email"
+              class="col-6"
+              label="E-mail Principal"
+              outlined
+              :error-message="validationErrors.email"
+              :rules="[inputRules.required, inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.secondary_email"
+              v-model:error="validationErrors.secondary_email"
+              class="col-6"
+              label="E-mail Administrativo"
+              outlined
+              :error-message="validationErrors.secondary_email"
+              :rules="[inputRules.email]"
+            />
+
+            <DefaultInput
+              v-model="form.phone_number"
+              v-model:error="validationErrors.phone_number"
+              class="col-6"
+              label="Telefone"
+              outlined
+              :error-message="validationErrors.phone_number"
+              :mask="masks.Brasil.telefone"
+            />
+
+            <DefaultInput
+              v-model="form.cell_number"
+              v-model:error="validationErrors.cell_number"
+              class="col-6"
+              label="Celular"
+              outlined
+              :error-message="validationErrors.cell_number"
+              :mask="masks.Brasil.celular"
+              :rules="[inputRules.required]"
             />
           </div>
         </div>
+      </q-scroll-area>
+
+      <q-separator />
+
+      <div class="row justify-end q-gutter-sm q-pt-md q-px-xs">
+        <q-btn
+          color="negative"
+          label="Cancelar"
+          no-caps
+          outline
+          @click="onCancel"
+        />
+
+        <q-btn
+          color="primary"
+          label="Salvar"
+          no-caps
+          type="submit"
+          unelevated
+          :loading="loading"
+        />
       </div>
-      </DefaultForm>
-    </q-scroll-area>
+    </DefaultForm>
   </div>
 </template>
 
 <script setup>
-import { ref, watch, onMounted } from "vue";
-import { useRouter } from "vue-router";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
-import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
-import StateSelect from "src/components/selects/StateSelect.vue";
-import CitySelect from "src/components/selects/CitySelect.vue";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  createUnit,
+  getUnit,
+  updateUnit,
+} from "src/api/unit";
+
+import { onMounted, ref, watch } from "vue";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useRouter } from "vue-router";
 import { useScroll } from "src/composables/useScroll";
-import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
-import { createUnit, getUnit, updateUnit } from "src/api/unit";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
 import masks from "src/helpers/masks";
 
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+
+import CitySelect from "src/components/selects/CitySelect.vue";
+import StateSelect from "src/components/selects/StateSelect.vue";
+
+import DefaultCepInput from "src/components/defaults/DefaultCepInput.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
 const props = defineProps({
   unitId: {
-    type: Number,
     default: null,
+    type: Number,
   },
 });
 
+const router = useRouter();
+const { inputRules } = useInputRules();
+
+const avatarRef = ref(null);
+const citySelectRef = ref(null);
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+const stateSelectRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
 const initialForm = {
-  name: null,
-  fantasy_name: "",
-  social_reason: null,
-  cnpj: null,
-  state_registration: null,
-  name_responsible: null,
-  street: null,
   address_number: null,
-  postal_code: null,
-  neighborhood: null,
-  complement: null,
+  cell_number: null,
   city_id: null,
-  state_id: null,
+  cnpj: null,
+  complement: null,
   email: null,
-  secondary_email: null,
+  fantasy_name: "",
+  name: null,
+  name_responsible: null,
+  neighborhood: null,
   phone_number: null,
-  cell_number: null,
+  postal_code: null,
+  secondary_email: null,
+  social_reason: null,
+  state_id: null,
+  state_registration: null,
+  street: null,
 };
 
-const router = useRouter();
-const formRef = ref(null);
-const scrollAreaRef = ref(null);
-const avatarRef = ref(null);
-const stateSelectRef = ref(null);
-const citySelectRef = ref(null);
-
-const selectedState = ref(null);
-const selectedCity = ref(null);
-
-const { inputRules } = useInputRules();
-const { scrollToComponent } = useScroll();
 const {
   form,
   getFormAsFormData,
   getUpdatedFieldsAsFormData,
   setUpdateFormAsOriginal,
-} = useFormUpdateTracker(initialForm, { containerRef: scrollAreaRef });
+} = useForm(initialForm);
 
-watch(selectedState, (state) => {
-  form.state_id = state?.value ?? null;
-});
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (result) => {
+    if (props.unitId) {
+      router.push({
+        name: "FranchiseePage",
+      });
 
-watch(selectedCity, (city) => {
-  form.city_id = city?.value ?? null;
+      return;
+    }
+
+    router.push({
+      name: "UnitEditPage",
+      params: {
+        id: result.id,
+      },
+    });
+  },
+  scrollFn: scrollToComponent,
 });
 
-function onAvatarChange(file) {
-  form.avatar = file;
-}
+const selectedCity = ref(null);
+const selectedState = ref(null);
 
-onMounted(async () => {
+const loadUnit = async () => {
   if (!props.unitId) return;
 
   try {
     const unit = await getUnit(props.unitId);
-
-    form.name = unit.name;
     const fantasyName = String(unit.fantasy_name ?? "").trim();
+
+    form.address_number = unit.address_number;
+    form.cell_number = unit.cell_number;
+    form.city_id = unit.city_id;
+    form.cnpj = unit.cnpj;
+    form.complement = unit.complement;
+    form.email = unit.email;
+
     form.fantasy_name = ["null", "undefined"].includes(
       fantasyName.toLowerCase(),
     )
       ? ""
       : fantasyName;
-    form.social_reason = unit.social_reason;
-    form.cnpj = unit.cnpj;
-    form.state_registration = unit.state_registration;
+
+    form.name = unit.name;
     form.name_responsible = unit.name_responsible;
-    form.street = unit.street;
-    form.address_number = unit.address_number;
-    form.postal_code = unit.postal_code;
     form.neighborhood = unit.neighborhood;
-    form.complement = unit.complement;
-    form.email = unit.email;
-    form.secondary_email = unit.secondary_email;
     form.phone_number = unit.phone_number;
-    form.cell_number = unit.cell_number;
+    form.postal_code = unit.postal_code;
+    form.secondary_email = unit.secondary_email;
+    form.social_reason = unit.social_reason;
     form.state_id = unit.state_id;
-    form.city_id = unit.city_id;
+    form.state_registration = unit.state_registration;
+    form.street = unit.street;
 
     if (unit.state_id) {
       stateSelectRef.value?.selectStateById(unit.state_id);
     }
+
     if (unit.city_id) {
       citySelectRef.value?.selectCityById(unit.city_id);
     }
+
     if (unit.avatar_url) {
       avatarRef.value?.setImageUrl(unit.avatar_url);
     }
 
     setUpdateFormAsOriginal();
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   }
-});
+};
 
-const { loading, validationErrors, execute } = useSubmitHandler({
-  formRef,
-  containerRef: scrollAreaRef,
-  scrollFn: scrollToComponent,
-  onSuccess: (result) => {
-    if (props.unitId) {
-      router.push({ name: "FranchiseePage" });
-    } else {
-      router.push({ name: "UnitEditPage", params: { id: result.id } });
-    }
-  },
-});
+const onAvatarChange = (file) => {
+  form.avatar = file;
+};
+
+const onCancel = () => {
+  router.push({
+    name: "FranchiseePage",
+  });
+};
 
-async function onSave() {
+const onSave = async () => {
   await execute(() => {
     if (props.unitId) {
-      return updateUnit(props.unitId, getUpdatedFieldsAsFormData());
+      return updateUnit(
+        props.unitId,
+        getUpdatedFieldsAsFormData(),
+      );
     }
+
     return createUnit(getFormAsFormData());
   });
-}
+};
 
-function onCancel() {
-  router.push({ name: "FranchiseePage" });
-}
+watch(selectedCity, (city) => {
+  form.city_id = city?.value ?? null;
+});
+
+watch(selectedState, (state) => {
+  form.state_id = state?.value ?? null;
+});
+
+onMounted(loadUnit);
 </script>
 
 <style scoped>
+.unit-data-tab {
+  box-sizing: border-box;
+  min-height: 0;
+}
+
+.unit-tab-form {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+}
+
 .unit-form-tab-scroll-area {
-  height: calc(100vh - 280px);
-  min-height: 320px;
+  flex: 1 1 auto;
+  min-height: 0;
 }
-</style>
+</style>

+ 43 - 14
src/pages/user_types/components/AddUserTypeDialog.vue

@@ -7,31 +7,38 @@
       />
 
       <DefaultForm ref="formRef" @submit="onSave">
-        <q-scroll-area class="form-dialog-scroll-area">
-          <q-card-section>
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
             <DefaultInput
               v-model="form.label"
               v-model:error="validationErrors.label"
-              :error-message="validationErrors.label"
+              autofocus
+              class="col-12"
               label="Nome"
               outlined
-              autofocus
+              :error-message="validationErrors.label"
               :rules="[inputRules.required]"
             />
           </q-card-section>
         </q-scroll-area>
 
-        <q-card-actions align="right">
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
           <q-btn
+            color="negative"
             label="Cancelar"
-            color="primary"
+            no-caps
             outline
             @click="onDialogCancel"
           />
+
           <q-btn
-            label="Salvar"
             color="primary"
+            label="Salvar"
+            no-caps
             type="submit"
+            unelevated
             :loading="loading"
           />
         </q-card-actions>
@@ -41,30 +48,52 @@
 </template>
 
 <script setup>
-import { ref, useTemplateRef } from "vue";
+import { createUserType } from "src/api/user_type";
 import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
 import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createUserType } from "src/api/user_type";
+import { useTemplateRef } from "vue";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
   useDialogPluginComponent();
 
 const { inputRules } = useInputRules();
+
 const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const { scrollToComponent } = useScroll();
 
-const form = ref({ label: "" });
+const { form } = useForm(
+  {
+    label: "",
+  },
+  { containerRef: scrollAreaRef },
+);
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
   formRef,
   onSuccess: () => onDialogOK(),
+  scrollFn: scrollToComponent,
 });
 
 const onSave = async () => {
-  await execute(() => createUserType({ label: form.value.label }));
+  await execute(() =>
+    createUserType({
+      label: form.label,
+    }),
+  );
 };
-</script>
+</script>

+ 230 - 127
src/pages/users/UserActionPage.vue

@@ -9,15 +9,19 @@
         <div class="column justify-center items-center q-mb-lg">
           <AvatarImageComponent
             ref="avatarRef"
-            @update:file="(f) => (avatarFile = f)"
+            @update:file="avatarFile = $event"
           />
 
-          <div class="row full-width q-mt-md q-col-gutter-sm">
+          <div class="row full-width q-mt-md q-col-gutter-md">
             <div class="col-12">
+              <div class="text-caption text-secondary q-mb-xs">
+                Escopo de acesso <span class="text-negative">*</span>
+              </div>
+
               <q-btn-toggle
                 v-model="form.access_scope"
-                spread
                 no-caps
+                spread
                 toggle-color="primary"
                 :options="[
                   { label: 'Acesso de unidade', value: 'unit' },
@@ -25,14 +29,15 @@
                 ]"
               />
             </div>
+
             <StateSelect
               v-if="form.access_scope === 'unit'"
               v-model="form.state"
               v-model:error="validationErrors.state_id"
-              :error-message="validationErrors.state_id"
               class="col-3"
-              outlined
               label="Estado / UF"
+              outlined
+              :error-message="validationErrors.state_id"
               :initial-id="editStateId"
             />
 
@@ -40,10 +45,10 @@
               v-if="form.access_scope === 'unit'"
               v-model="form.units"
               v-model:error="validationErrors.unit_ids"
-              :error-message="validationErrors.unit_ids"
               class="col-9"
-              outlined
               label="Unidades"
+              outlined
+              :error-message="validationErrors.unit_ids"
               :initial-ids="editUnitIds"
             />
 
@@ -51,66 +56,66 @@
               :key="form.access_scope"
               v-model="form.user_type"
               v-model:error="validationErrors.user_type"
-              :error-message="validationErrors.user_type"
-              :access-scope="form.access_scope"
-              :unit-id="selectedUnitId"
               class="col-6"
-              outlined
               label="Tipo de Usuário"
+              outlined
+              :access-scope="form.access_scope"
+              :error-message="validationErrors.user_type"
               :rules="[inputRules.required]"
+              :unit-id="selectedUnitId"
             />
 
             <DefaultInput
               v-model="form.cpf"
               v-model:error="validationErrors.cpf"
-              :error-message="validationErrors.cpf"
-              label="CPF"
               class="col-6"
-              outlined
+              label="CPF"
               mask="###.###.###-##"
+              outlined
+              :error-message="validationErrors.cpf"
               :rules="[inputRules.cpf]"
             />
 
             <DefaultInput
               v-model="form.name"
               v-model:error="validationErrors.name"
-              :error-message="validationErrors.name"
-              label="Nome"
               class="col-6"
+              label="Nome"
               outlined
+              :error-message="validationErrors.name"
               :rules="[inputRules.required]"
             />
 
             <DefaultInput
               v-model="form.email"
               v-model:error="validationErrors.email"
-              :error-message="validationErrors.email"
-              label="E-mail"
               class="col-6"
+              label="E-mail"
               outlined
               type="email"
+              :error-message="validationErrors.email"
               :rules="[inputRules.required, inputRules.email]"
             />
 
             <DefaultInput
               v-model="form.password"
               v-model:error="validationErrors.password"
-              :error-message="validationErrors.password"
-              label="Senha"
               class="col-6"
+              label="Senha"
               outlined
-              :type="showPassword ? 'text' : 'password'"
+              :error-message="validationErrors.password"
               :rules="
                 isEdit
-                  ? [(v) => !v || v.length >= 8 || 'Mínimo 8 caracteres']
+                  ? [(value) => !value || value.length >= 8 || 'Mínimo 8 caracteres']
                   : [inputRules.required, inputRules.min(8)]
               "
+              :type="showPassword ? 'text' : 'password'"
             >
               <template #append>
                 <q-icon
-                  :name="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
                   class="cursor-pointer"
                   color="secondary"
+                  :name="showPassword ? 'mdi-eye-off' : 'mdi-eye'"
                   @click="showPassword = !showPassword"
                 />
               </template>
@@ -119,17 +124,16 @@
             <DefaultInput
               v-model="form.password_confirmation"
               v-model:error="validationErrors.password_confirmation"
-              :error-message="validationErrors.password_confirmation"
-              label="Repetir Senha"
               class="col-6"
+              label="Repetir Senha"
               outlined
-              :type="showPasswordConfirm ? 'text' : 'password'"
+              :error-message="validationErrors.password_confirmation"
               :rules="
                 isEdit
                   ? [
-                      (v) =>
+                      (value) =>
                         !form.password ||
-                        v === form.password ||
+                        value === form.password ||
                         'Senhas não conferem',
                     ]
                   : [
@@ -137,26 +141,31 @@
                       inputRules.samePassword(form.password),
                     ]
               "
+              :type="showPasswordConfirm ? 'text' : 'password'"
             >
               <template #append>
                 <q-icon
-                  :name="showPasswordConfirm ? 'mdi-eye-off' : 'mdi-eye'"
                   class="cursor-pointer"
                   color="secondary"
+                  :name="showPasswordConfirm ? 'mdi-eye-off' : 'mdi-eye'"
                   @click="showPasswordConfirm = !showPasswordConfirm"
                 />
               </template>
             </DefaultInput>
 
-            <div v-if="isEdit" class="col-12 text-caption text-grey-6">
+            <div
+              v-if="isEdit"
+              class="col-12 text-caption text-grey-6"
+            >
               Deixe os campos de senha em branco para manter a senha atual.
             </div>
 
             <div class="col-6 flex items-center">
               <q-btn
-                label="Gerar senha segura"
-                icon="mdi-thumb-up-outline"
                 color="primary"
+                icon="mdi-thumb-up-outline"
+                label="Gerar senha segura"
+                no-caps
                 @click="generatePassword"
               />
             </div>
@@ -165,9 +174,11 @@
           <div class="row justify-end q-mt-md full-width">
             <q-btn
               v-if="canSave"
-              label="SALVAR"
-              icon-right="mdi-check"
               color="primary"
+              icon-right="mdi-check"
+              label="Salvar"
+              no-caps
+              unelevated
               :loading="loading"
               @click="onSave"
             />
@@ -179,75 +190,200 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from "vue";
-import { useRouter, useRoute } from "vue-router";
+import { computed, onMounted, ref, watch } from "vue";
+
+import {
+  createUser,
+  getUserById,
+  updateUser,
+} from "src/api/user";
+
+import { permissionStore } from "src/stores/permission";
+
+import {
+  useRoute,
+  useRouter,
+} from "vue-router";
+
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
-import AvatarImageComponent from "src/components/shared/AvatarImageComponent.vue";
+
 import StateSelect from "src/components/selects/StateSelect.vue";
 import UnitMultiSelect from "src/components/selects/UnitMultiSelect.vue";
 import UserTypeSelect from "src/components/selects/UserTypeSelect.vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createUser, updateUser, getUserById } from "src/api/user";
-import { permissionStore } from "src/stores/permission";
 
-const router = useRouter();
 const route = useRoute();
-const { inputRules } = useInputRules();
+const router = useRouter();
 
-const formRef = ref(null);
-const avatarRef = ref(null);
-const avatarFile = ref(null);
+const { inputRules } = useInputRules();
 
-const showPassword = ref(false);
-const showPasswordConfirm = ref(false);
+const permissions = permissionStore();
 
+const avatarFile = ref(null);
+const avatarRef = ref(null);
 const editStateId = ref(null);
 const editUnitIds = ref([]);
-const loadingUser = ref(false);
+const formRef = ref(null);
 
-const isEdit = computed(() => !!route.params.id);
-const permissions = permissionStore();
-const canSave = computed(() =>
-  permissions.getAccess(
-    "franchisor_registrations",
-    isEdit.value ? "edit" : "add",
-  ),
-);
-const selectedUnitId = computed(() => form.value.units?.[0]?.value ?? null);
+const { scrollToComponent } = useScroll();
 
-const form = ref({
+const { form, getUpdatedFields, setUpdateFormAsOriginal } = useForm({
   access_scope: "unit",
-  state: null,
-  units: [],
-  user_type: null,
   cpf: null,
-  name: null,
   email: null,
+  name: null,
   password: null,
   password_confirmation: null,
+  state: null,
+  units: [],
+  user_type: null,
 });
 
-const { loading, validationErrors, execute } = useSubmitHandler({
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
   formRef,
   onSuccess: () => {
-    router.push({ name: "CadastrosPage" });
+    router.push({
+      name: "CadastrosPage",
+    });
   },
+  scrollFn: scrollToComponent,
 });
 
-onMounted(async () => {
+const loadingUser = ref(false);
+const showPassword = ref(false);
+const showPasswordConfirm = ref(false);
+
+const isEdit = computed(() => !!route.params.id);
+
+const canSave = computed(() =>
+  permissions.getAccess(
+    "franchisor_registrations",
+    isEdit.value ? "edit" : "add",
+  ),
+);
+
+const selectedUnitId = computed(() => form.units?.[0]?.value ?? null);
+
+const buildFormData = () => {
+  const formData = new FormData();
+
+  if (avatarFile.value) {
+    formData.append("avatar", avatarFile.value);
+  }
+
+  formData.append("access_scope", form.access_scope);
+
+  if (form.state?.value) {
+    formData.append("state_id", form.state.value);
+  }
+
+  form.units?.forEach((unit) => {
+    formData.append("unit_ids[]", unit.value);
+  });
+
+  if (form.user_type) {
+    formData.append("user_type", form.user_type);
+  }
+
+  if (form.cpf) {
+    formData.append("cpf", form.cpf);
+  }
+
+  formData.append("email", form.email ?? "");
+  formData.append("name", form.name ?? "");
+  formData.append("password", form.password ?? "");
+
+  return formData;
+};
+
+const buildUpdateData = () => {
+  const formData = new FormData();
+  const updated = getUpdatedFields.value;
+
+  formData.append("_method", "PUT");
+
+  if (avatarFile.value) {
+    formData.append("avatar", avatarFile.value);
+  }
+
+  if (updated.access_scope !== undefined) {
+    formData.append("access_scope", updated.access_scope);
+  }
+
+  if (updated.state !== undefined && updated.state?.value) {
+    formData.append("state_id", updated.state.value);
+  }
+
+  if (updated.units !== undefined) {
+    (updated.units ?? []).forEach((unit) => {
+      formData.append("unit_ids[]", unit.value);
+    });
+  }
+
+  ["cpf", "email", "name", "password", "user_type"].forEach((field) => {
+    if (updated[field] !== undefined) {
+      formData.append(field, updated[field] ?? "");
+    }
+  });
+
+  return formData;
+};
+
+const generatePassword = () => {
+  const chars =
+    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%";
+
+  const password = Array.from({ length: 12 }, () =>
+    chars.charAt(Math.floor(Math.random() * chars.length)),
+  ).join("");
+
+  form.password = password;
+  form.password_confirmation = password;
+};
+
+const loadUser = async () => {
   if (!isEdit.value) return;
+
   loadingUser.value = true;
+
   try {
     const user = await getUserById(route.params.id);
-    form.value.name = user.name;
-    form.value.email = user.email;
-    form.value.cpf = user.cpf;
-    form.value.user_type = user.user_type;
-    form.value.access_scope = user.access_scope ?? (user.units?.length ? "unit" : "franchisor");
+
+    form.access_scope =
+      user.access_scope ?? (user.units?.length ? "unit" : "franchisor");
+
+    form.cpf = user.cpf;
+    form.email = user.email;
+    form.name = user.name;
+
+    form.state = user.state_id
+      ? {
+          value: user.state_id,
+        }
+      : null;
+
+    form.units = (user.units ?? []).map((unit) => ({
+      value: unit.id,
+    }));
+
+    form.user_type = user.user_type;
+
     editStateId.value = user.state_id ?? null;
-    editUnitIds.value = user.units?.map((u) => u.id) ?? [];
+    editUnitIds.value = user.units?.map((unit) => unit.id) ?? [];
+
+    setUpdateFormAsOriginal();
+
     if (user.avatar_url) {
       avatarRef.value?.setImageUrl(user.avatar_url);
     }
@@ -256,70 +392,37 @@ onMounted(async () => {
   } finally {
     loadingUser.value = false;
   }
-});
+};
 
-function buildFormData() {
-  const fd = new FormData();
-
-  if (avatarFile.value) fd.append("avatar", avatarFile.value);
-  fd.append("access_scope", form.value.access_scope);
-  if (form.value.state?.value) fd.append("state_id", form.value.state.value);
-  form.value.units?.forEach((u) => fd.append("unit_ids[]", u.value));
-  if (form.value.user_type) fd.append("user_type", form.value.user_type);
-  if (form.value.cpf) fd.append("cpf", form.value.cpf);
-
-  fd.append("name", form.value.name ?? "");
-  fd.append("email", form.value.email ?? "");
-  fd.append("password", form.value.password ?? "");
-
-  return fd;
-}
-
-function buildUpdateData() {
-  const fd = new FormData();
-  fd.append("_method", "PUT");
-
-  if (avatarFile.value) fd.append("avatar", avatarFile.value);
-  fd.append("access_scope", form.value.access_scope);
-  if (form.value.state?.value) fd.append("state_id", form.value.state.value);
-  // Sempre envia unit_ids[] para sincronizar (array vazio limpa todas as unidades)
-  (form.value.units ?? []).forEach((u) => fd.append("unit_ids[]", u.value));
-  if (form.value.user_type) fd.append("user_type", form.value.user_type);
-  if (form.value.cpf) fd.append("cpf", form.value.cpf);
-  if (form.value.name) fd.append("name", form.value.name);
-  if (form.value.email) fd.append("email", form.value.email);
-  if (form.value.password) fd.append("password", form.value.password);
-
-  return fd;
-}
-
-async function onSave() {
+const onSave = async () => {
   if (isEdit.value) {
-    await execute(() => updateUser(buildUpdateData(), route.params.id));
-  } else {
-    await execute(() => createUser(buildFormData()));
+    await execute(() =>
+      updateUser(
+        buildUpdateData(),
+        route.params.id,
+      ),
+    );
+
+    return;
   }
-}
 
-function generatePassword() {
-  const chars =
-    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%";
-  const password = Array.from({ length: 12 }, () =>
-    chars.charAt(Math.floor(Math.random() * chars.length)),
-  ).join("");
-  form.value.password = password;
-  form.value.password_confirmation = password;
-}
+  await execute(() => createUser(buildFormData()));
+};
 
 watch(
-  () => form.value.access_scope,
+  () => form.access_scope,
   (scope, previousScope) => {
-    if (previousScope && !loadingUser.value) form.value.user_type = null;
+    if (previousScope && !loadingUser.value) {
+      form.user_type = null;
+    }
+
     if (scope === "franchisor") {
-      form.value.state = null;
-      form.value.units = [];
+      form.state = null;
+      form.units = [];
     }
   },
   { flush: "sync" },
 );
-</script>
+
+onMounted(loadUser);
+</script>

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